diff --git a/.ci/teamcity/bootstrap.sh b/.ci/teamcity/bootstrap.sh new file mode 100755 index 00000000000000..adb884ca064ba5 --- /dev/null +++ b/.ci/teamcity/bootstrap.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/util.sh" + +tc_start_block "Bootstrap" + +tc_start_block "yarn install and kbn bootstrap" +verify_no_git_changes yarn kbn bootstrap --prefer-offline +tc_end_block "yarn install and kbn bootstrap" + +tc_start_block "build kbn-pm" +verify_no_git_changes yarn kbn run build -i @kbn/pm +tc_end_block "build kbn-pm" + +tc_start_block "build plugin list docs" +verify_no_git_changes node scripts/build_plugin_list_docs +tc_end_block "build plugin list docs" + +tc_end_block "Bootstrap" diff --git a/.ci/teamcity/checks/bundle_limits.sh b/.ci/teamcity/checks/bundle_limits.sh new file mode 100755 index 00000000000000..3f7daef6d04731 --- /dev/null +++ b/.ci/teamcity/checks/bundle_limits.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +node scripts/build_kibana_platform_plugins --validate-limits diff --git a/.ci/teamcity/checks/doc_api_changes.sh b/.ci/teamcity/checks/doc_api_changes.sh new file mode 100755 index 00000000000000..821647a39441cf --- /dev/null +++ b/.ci/teamcity/checks/doc_api_changes.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:checkDocApiChanges diff --git a/.ci/teamcity/checks/file_casing.sh b/.ci/teamcity/checks/file_casing.sh new file mode 100755 index 00000000000000..66578a4970fec8 --- /dev/null +++ b/.ci/teamcity/checks/file_casing.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:checkFileCasing diff --git a/.ci/teamcity/checks/i18n.sh b/.ci/teamcity/checks/i18n.sh new file mode 100755 index 00000000000000..f269816cf6b95b --- /dev/null +++ b/.ci/teamcity/checks/i18n.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:i18nCheck diff --git a/.ci/teamcity/checks/licenses.sh b/.ci/teamcity/checks/licenses.sh new file mode 100755 index 00000000000000..2baca870746301 --- /dev/null +++ b/.ci/teamcity/checks/licenses.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:licenses diff --git a/.ci/teamcity/checks/telemetry.sh b/.ci/teamcity/checks/telemetry.sh new file mode 100755 index 00000000000000..6413584d2057d0 --- /dev/null +++ b/.ci/teamcity/checks/telemetry.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:telemetryCheck diff --git a/.ci/teamcity/checks/test_hardening.sh b/.ci/teamcity/checks/test_hardening.sh new file mode 100755 index 00000000000000..21ee68e5ade700 --- /dev/null +++ b/.ci/teamcity/checks/test_hardening.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:test_hardening diff --git a/.ci/teamcity/checks/ts_projects.sh b/.ci/teamcity/checks/ts_projects.sh new file mode 100755 index 00000000000000..8afc195fee5557 --- /dev/null +++ b/.ci/teamcity/checks/ts_projects.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:checkTsProjects diff --git a/.ci/teamcity/checks/type_check.sh b/.ci/teamcity/checks/type_check.sh new file mode 100755 index 00000000000000..da8ae3373d976e --- /dev/null +++ b/.ci/teamcity/checks/type_check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:typeCheck diff --git a/.ci/teamcity/checks/verify_dependency_versions.sh b/.ci/teamcity/checks/verify_dependency_versions.sh new file mode 100755 index 00000000000000..4c2ddf5ce8612c --- /dev/null +++ b/.ci/teamcity/checks/verify_dependency_versions.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:verifyDependencyVersions diff --git a/.ci/teamcity/checks/verify_notice.sh b/.ci/teamcity/checks/verify_notice.sh new file mode 100755 index 00000000000000..8571e0bbceb13a --- /dev/null +++ b/.ci/teamcity/checks/verify_notice.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:verifyNotice diff --git a/.ci/teamcity/ci_stats.js b/.ci/teamcity/ci_stats.js new file mode 100644 index 00000000000000..2953661eca1fd9 --- /dev/null +++ b/.ci/teamcity/ci_stats.js @@ -0,0 +1,59 @@ +const https = require('https'); +const token = process.env.CI_STATS_TOKEN; +const host = process.env.CI_STATS_HOST; + +const request = (url, options, data = null) => { + const httpOptions = { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `token ${token}`, + }, + }; + + return new Promise((resolve, reject) => { + console.log(`Calling https://${host}${url}`); + + const req = https.request(`https://${host}${url}`, httpOptions, (res) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + return reject(new Error(`Status Code: ${res.statusCode}`)); + } + + const data = []; + res.on('data', (d) => { + data.push(d); + }) + + res.on('end', () => { + try { + let resp = Buffer.concat(data).toString(); + + try { + if (resp.trim()) { + resp = JSON.parse(resp); + } + } catch (ex) { + console.error(ex); + } + + resolve(resp); + } catch (ex) { + reject(ex); + } + }); + }) + + req.on('error', reject); + + if (data) { + req.write(JSON.stringify(data)); + } + + req.end(); + }); +} + +module.exports = { + get: (url) => request(url, { method: 'GET' }), + post: (url, data) => request(url, { method: 'POST' }, data), +} diff --git a/.ci/teamcity/ci_stats_complete.js b/.ci/teamcity/ci_stats_complete.js new file mode 100644 index 00000000000000..0df9329167ff65 --- /dev/null +++ b/.ci/teamcity/ci_stats_complete.js @@ -0,0 +1,18 @@ +const ciStats = require('./ci_stats'); + +// This might be better as an API call in the future. +// Instead, it relies on a separate step setting the BUILD_STATUS env var. BUILD_STATUS is not something provided by TeamCity. +const BUILD_STATUS = process.env.BUILD_STATUS === 'SUCCESS' ? 'SUCCESS' : 'FAILURE'; + +(async () => { + try { + if (process.env.CI_STATS_BUILD_ID) { + await ciStats.post(`/v1/build/_complete?id=${process.env.CI_STATS_BUILD_ID}`, { + result: BUILD_STATUS, + }); + } + } catch (ex) { + console.error(ex); + process.exit(1); + } +})(); diff --git a/.ci/teamcity/default/accessibility.sh b/.ci/teamcity/default/accessibility.sh new file mode 100755 index 00000000000000..2868db9d067b84 --- /dev/null +++ b/.ci/teamcity/default/accessibility.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-accessibility +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-default" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "X-Pack accessibility tests" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --config test/accessibility/config.ts diff --git a/.ci/teamcity/default/build.sh b/.ci/teamcity/default/build.sh new file mode 100755 index 00000000000000..af90e24ef5fe82 --- /dev/null +++ b/.ci/teamcity/default/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +tc_start_block "Build Platform Plugins" +node scripts/build_kibana_platform_plugins \ + --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ + --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ + --scan-dir "$XPACK_DIR/test/alerting_api_integration/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_api_integration/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \ + --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ + --verbose +tc_end_block "Build Platform Plugins" + +export KBN_NP_PLUGINS_BUILT=true + +tc_start_block "Build Default Distribution" + +cd "$KIBANA_DIR" +node scripts/build --debug --no-oss +linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" +installDir="$KIBANA_DIR/install/kibana" +mkdir -p "$installDir" +tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + +tc_end_block "Build Default Distribution" diff --git a/.ci/teamcity/default/build_plugins.sh b/.ci/teamcity/default/build_plugins.sh new file mode 100755 index 00000000000000..76c553b4f8fa24 --- /dev/null +++ b/.ci/teamcity/default/build_plugins.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +tc_start_block "Build Platform Plugins" +node scripts/build_kibana_platform_plugins \ + --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ + --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ + --scan-dir "$XPACK_DIR/test/alerting_api_integration/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_api_integration/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \ + --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ + --verbose +tc_end_block "Build Platform Plugins" + +tc_set_env KBN_NP_PLUGINS_BUILT true diff --git a/.ci/teamcity/default/ci_group.sh b/.ci/teamcity/default/ci_group.sh new file mode 100755 index 00000000000000..26c2c563210ed3 --- /dev/null +++ b/.ci/teamcity/default/ci_group.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export CI_GROUP="$1" +export JOB=kibana-default-ciGroup${CI_GROUP} +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-default" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Default Distro Chrome Functional tests / Group ${CI_GROUP}" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "ciGroup$CI_GROUP" diff --git a/.ci/teamcity/default/firefox.sh b/.ci/teamcity/default/firefox.sh new file mode 100755 index 00000000000000..5922a72bd5e85a --- /dev/null +++ b/.ci/teamcity/default/firefox.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-firefoxSmoke +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-default" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "X-Pack firefox smoke test" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "includeFirefox" \ + --config test/functional/config.firefox.js \ + --config test/functional_embedded/config.firefox.ts diff --git a/.ci/teamcity/default/saved_object_field_metrics.sh b/.ci/teamcity/default/saved_object_field_metrics.sh new file mode 100755 index 00000000000000..f5b57ce3b06eb9 --- /dev/null +++ b/.ci/teamcity/default/saved_object_field_metrics.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-savedObjectFieldMetrics +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-default" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Capture Kibana Saved Objects field count metrics" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --config test/saved_objects_field_count/config.ts diff --git a/.ci/teamcity/default/security_solution.sh b/.ci/teamcity/default/security_solution.sh new file mode 100755 index 00000000000000..46048f6c82d52b --- /dev/null +++ b/.ci/teamcity/default/security_solution.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-securitySolution +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-default" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Security Solution Cypress Tests" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --config test/security_solution_cypress/cli_config.ts diff --git a/.ci/teamcity/es_snapshots/build.sh b/.ci/teamcity/es_snapshots/build.sh new file mode 100755 index 00000000000000..f983713e80f4d5 --- /dev/null +++ b/.ci/teamcity/es_snapshots/build.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +cd .. +destination="$(pwd)/es-build" +mkdir -p "$destination" + +cd elasticsearch + +# These turn off automation in the Elasticsearch repo +export BUILD_NUMBER="" +export JENKINS_URL="" +export BUILD_URL="" +export JOB_NAME="" +export NODE_NAME="" + +# Reads the ES_BUILD_JAVA env var out of .ci/java-versions.properties and exports it +export "$(grep '^ES_BUILD_JAVA' .ci/java-versions.properties | xargs)" + +export PATH="$HOME/.java/$ES_BUILD_JAVA/bin:$PATH" +export JAVA_HOME="$HOME/.java/$ES_BUILD_JAVA" + +tc_start_block "Build Elasticsearch" +./gradlew -Dbuild.docker=true assemble --parallel +tc_end_block "Build Elasticsearch" + +tc_start_block "Create distribution archives" +find distribution -type f \( -name 'elasticsearch-*-*-*-*.tar.gz' -o -name 'elasticsearch-*-*-*-*.zip' \) -not -path '*no-jdk*' -not -path '*build-context*' -exec cp {} "$destination" \; +tc_end_block "Create distribution archives" + +ls -alh "$destination" + +tc_start_block "Create docker image archives" +docker images "docker.elastic.co/elasticsearch/elasticsearch" +docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 echo 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' +docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' +tc_end_block "Create docker image archives" + +cd "$destination" + +find ./* -exec bash -c "shasum -a 512 {} > {}.sha512" \; +ls -alh "$destination" diff --git a/.ci/teamcity/es_snapshots/create_manifest.js b/.ci/teamcity/es_snapshots/create_manifest.js new file mode 100644 index 00000000000000..63e54987f788f7 --- /dev/null +++ b/.ci/teamcity/es_snapshots/create_manifest.js @@ -0,0 +1,82 @@ +const fs = require('fs'); +const { execSync } = require('child_process'); + +(async () => { + const destination = process.argv[2] || __dirname + '/test'; + + let ES_BRANCH = process.env.ELASTICSEARCH_BRANCH; + let GIT_COMMIT = process.env.ELASTICSEARCH_GIT_COMMIT; + let GIT_COMMIT_SHORT = execSync(`git rev-parse --short '${GIT_COMMIT}'`).toString().trim(); + + let VERSION = ''; + let SNAPSHOT_ID = ''; + let DESTINATION = ''; + + const now = new Date() + + // format: yyyyMMdd-HHmmss + const date = [ + now.getFullYear(), + (now.getMonth()+1).toString().padStart(2, '0'), + now.getDate().toString().padStart(2, '0'), + '-', + now.getHours().toString().padStart(2, '0'), + now.getMinutes().toString().padStart(2, '0'), + now.getSeconds().toString().padStart(2, '0'), + ].join('') + + try { + const files = fs.readdirSync(destination); + const manifestEntries = files + .filter(f => !f.match(/.sha512$/)) + .filter(f => !f.match(/.json$/)) + .map(filename => { + const parts = filename.replace("elasticsearch-oss", "oss").split("-") + + VERSION = VERSION || parts[1]; + SNAPSHOT_ID = SNAPSHOT_ID || `${date}_${GIT_COMMIT_SHORT}`; + DESTINATION = DESTINATION || `${VERSION}/archives/${SNAPSHOT_ID}`; + + return { + filename: filename, + checksum: filename + '.sha512', + url: `https://storage.googleapis.com/kibana-ci-es-snapshots-daily-teamcity/${DESTINATION}/${filename}`, + version: parts[1], + platform: parts[3], + architecture: parts[4].split('.')[0], + license: parts[0] == 'oss' ? 'oss' : 'default', + } + }); + + const manifest = { + id: SNAPSHOT_ID, + bucket: `kibana-ci-es-snapshots-daily-teamcity/${DESTINATION}`.toString(), + branch: ES_BRANCH, + sha: GIT_COMMIT, + sha_short: GIT_COMMIT_SHORT, + version: VERSION, + generated: now.toISOString(), + archives: manifestEntries, + }; + + const manifestJSON = JSON.stringify(manifest, null, 2); + fs.writeFileSync(`${destination}/manifest.json`, manifestJSON); + + execSync(` + set -euo pipefail + cd "${destination}" + gsutil -m cp -r *.* gs://kibana-ci-es-snapshots-daily-teamcity/${DESTINATION} + cp manifest.json manifest-latest.json + gsutil cp manifest-latest.json gs://kibana-ci-es-snapshots-daily-teamcity/${VERSION} + `, { shell: '/bin/bash' }); + + console.log(`##teamcity[setParameter name='env.ES_SNAPSHOT_MANIFEST' value='https://storage.googleapis.com/kibana-ci-es-snapshots-daily-teamcity/${DESTINATION}/manifest.json']`); + console.log(`##teamcity[setParameter name='env.ES_SNAPSHOT_VERSION' value='${VERSION}']`); + console.log(`##teamcity[setParameter name='env.ES_SNAPSHOT_ID' value='${SNAPSHOT_ID}']`); + + console.log(`##teamcity[buildNumber '{build.number}-${VERSION}-${SNAPSHOT_ID}']`); + } catch (ex) { + console.error(ex); + process.exit(1); + } +})(); diff --git a/.ci/teamcity/es_snapshots/promote_manifest.js b/.ci/teamcity/es_snapshots/promote_manifest.js new file mode 100644 index 00000000000000..bcc79e696d7839 --- /dev/null +++ b/.ci/teamcity/es_snapshots/promote_manifest.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const { execSync } = require('child_process'); + +const BASE_BUCKET_DAILY = 'kibana-ci-es-snapshots-daily-teamcity'; +const BASE_BUCKET_PERMANENT = 'kibana-ci-es-snapshots-daily-teamcity/permanent'; + +(async () => { + try { + const MANIFEST_URL = process.argv[2]; + + if (!MANIFEST_URL) { + throw Error('Manifest URL missing'); + } + + if (!fs.existsSync('snapshot-promotion')) { + fs.mkdirSync('snapshot-promotion'); + } + process.chdir('snapshot-promotion'); + + execSync(`curl '${MANIFEST_URL}' > manifest.json`); + + const manifest = JSON.parse(fs.readFileSync('manifest.json')); + const { id, bucket, version } = manifest; + + console.log(`##teamcity[buildNumber '{build.number}-${version}-${id}']`); + + const manifestPermanent = { + ...manifest, + bucket: bucket.replace(BASE_BUCKET_DAILY, BASE_BUCKET_PERMANENT), + }; + + fs.writeFileSync('manifest-permanent.json', JSON.stringify(manifestPermanent, null, 2)); + + execSync( + ` + set -euo pipefail + + cp manifest.json manifest-latest-verified.json + gsutil cp manifest-latest-verified.json gs://${BASE_BUCKET_DAILY}/${version}/ + + rm manifest.json + cp manifest-permanent.json manifest.json + gsutil -m cp -r gs://${bucket}/* gs://${BASE_BUCKET_PERMANENT}/${version}/ + gsutil cp manifest.json gs://${BASE_BUCKET_PERMANENT}/${version}/ + + `, + { shell: '/bin/bash' } + ); + } catch (ex) { + console.error(ex); + process.exit(1); + } +})(); diff --git a/.ci/teamcity/oss/accessibility.sh b/.ci/teamcity/oss/accessibility.sh new file mode 100755 index 00000000000000..09693d7ebdc57b --- /dev/null +++ b/.ci/teamcity/oss/accessibility.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-accessibility +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-oss" + +checks-reporter-with-killswitch "Kibana accessibility tests" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --config test/accessibility/config.ts diff --git a/.ci/teamcity/oss/build.sh b/.ci/teamcity/oss/build.sh new file mode 100755 index 00000000000000..3ef14b16633552 --- /dev/null +++ b/.ci/teamcity/oss/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +tc_start_block "Build Platform Plugins" +node scripts/build_kibana_platform_plugins \ + --oss \ + --filter '!alertingExample' \ + --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ + --verbose +tc_end_block "Build Platform Plugins" + +export KBN_NP_PLUGINS_BUILT=true + +tc_start_block "Build OSS Distribution" +node scripts/build --debug --oss + +# Renaming the build directory to a static one, so that we can put a static one in the TeamCity artifact rules +mv build/oss/kibana-*-SNAPSHOT-linux-x86_64 build/oss/kibana-build-oss +tc_end_block "Build OSS Distribution" diff --git a/.ci/teamcity/oss/build_plugins.sh b/.ci/teamcity/oss/build_plugins.sh new file mode 100755 index 00000000000000..28e3c9247f1d40 --- /dev/null +++ b/.ci/teamcity/oss/build_plugins.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +tc_start_block "Build Platform Plugins - OSS" + +node scripts/build_kibana_platform_plugins \ + --oss \ + --filter '!alertingExample' \ + --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ + --scan-dir "$KIBANA_DIR/test/common/fixtures/plugins" \ + --verbose +tc_end_block "Build Platform Plugins - OSS" diff --git a/.ci/teamcity/oss/ci_group.sh b/.ci/teamcity/oss/ci_group.sh new file mode 100755 index 00000000000000..3b2fb7ea912b76 --- /dev/null +++ b/.ci/teamcity/oss/ci_group.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export CI_GROUP="$1" +export JOB="kibana-ciGroup$CI_GROUP" +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-oss" + +checks-reporter-with-killswitch "Functional tests / Group $CI_GROUP" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "ciGroup$CI_GROUP" diff --git a/.ci/teamcity/oss/firefox.sh b/.ci/teamcity/oss/firefox.sh new file mode 100755 index 00000000000000..5e2a6c17c00527 --- /dev/null +++ b/.ci/teamcity/oss/firefox.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-firefoxSmoke +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-oss" + +checks-reporter-with-killswitch "Firefox smoke test" \ + node scripts/functional_tests \ + --bail --debug \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "includeFirefox" \ + --config test/functional/config.firefox.js diff --git a/.ci/teamcity/oss/plugin_functional.sh b/.ci/teamcity/oss/plugin_functional.sh new file mode 100755 index 00000000000000..41ff549945c0b4 --- /dev/null +++ b/.ci/teamcity/oss/plugin_functional.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-pluginFunctional +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-oss" + +cd test/plugin_functional/plugins/kbn_sample_panel_action +if [[ ! -d "target" ]]; then + yarn build +fi +cd - + +yarn run grunt run:pluginFunctionalTestsRelease --from=source +yarn run grunt run:exampleFunctionalTestsRelease --from=source +yarn run grunt run:interpreterFunctionalTestsRelease diff --git a/.ci/teamcity/setup_ci_stats.js b/.ci/teamcity/setup_ci_stats.js new file mode 100644 index 00000000000000..6b381530d9bb7f --- /dev/null +++ b/.ci/teamcity/setup_ci_stats.js @@ -0,0 +1,33 @@ +const ciStats = require('./ci_stats'); + +(async () => { + try { + const build = await ciStats.post('/v1/build', { + jenkinsJobName: process.env.TEAMCITY_BUILDCONF_NAME, + jenkinsJobId: process.env.TEAMCITY_BUILD_ID, + jenkinsUrl: process.env.TEAMCITY_BUILD_URL, + prId: process.env.GITHUB_PR_NUMBER || null, + }); + + const config = { + apiUrl: `https://${process.env.CI_STATS_HOST}`, + apiToken: process.env.CI_STATS_TOKEN, + buildId: build.id, + }; + + const configJson = JSON.stringify(config); + process.env.KIBANA_CI_STATS_CONFIG = configJson; + console.log(`\n##teamcity[setParameter name='env.KIBANA_CI_STATS_CONFIG' display='hidden' password='true' value='${configJson}']\n`); + console.log(`\n##teamcity[setParameter name='env.CI_STATS_BUILD_ID' value='${build.id}']\n`); + + await ciStats.post(`/v1/git_info?buildId=${build.id}`, { + branch: process.env.GIT_BRANCH.replace(/^(refs\/heads\/|origin\/)/, ''), + commit: process.env.GIT_COMMIT, + targetBranch: process.env.GITHUB_PR_TARGET_BRANCH || null, + mergeBase: null, // TODO + }); + } catch (ex) { + console.error(ex); + process.exit(1); + } +})(); diff --git a/.ci/teamcity/setup_env.sh b/.ci/teamcity/setup_env.sh new file mode 100755 index 00000000000000..f662d36247a2fd --- /dev/null +++ b/.ci/teamcity/setup_env.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/util.sh" + +tc_set_env KIBANA_DIR "$(cd "$(dirname "$0")/../.." && pwd)" +tc_set_env XPACK_DIR "$KIBANA_DIR/x-pack" + +tc_set_env CACHE_DIR "$HOME/.kibana" +tc_set_env PARENT_DIR "$(cd "$KIBANA_DIR/.."; pwd)" +tc_set_env WORKSPACE "${WORKSPACE:-$PARENT_DIR}" + +tc_set_env KIBANA_PKG_BRANCH "$(jq -r .branch "$KIBANA_DIR/package.json")" +tc_set_env KIBANA_BASE_BRANCH "$KIBANA_PKG_BRANCH" + +tc_set_env GECKODRIVER_CDNURL "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache" +tc_set_env CHROMEDRIVER_CDNURL "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache" +tc_set_env RE2_DOWNLOAD_MIRROR "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache" +tc_set_env CYPRESS_DOWNLOAD_MIRROR "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/cypress" + +tc_set_env NODE_OPTIONS "${NODE_OPTIONS:-} --max-old-space-size=4096" + +tc_set_env FORCE_COLOR 1 +tc_set_env TEST_BROWSER_HEADLESS 1 + +tc_set_env ELASTIC_APM_ENVIRONMENT ci + +if [[ "${KIBANA_CI_REPORTER_KEY_BASE64-}" ]]; then + tc_set_env KIBANA_CI_REPORTER_KEY "$(echo "$KIBANA_CI_REPORTER_KEY_BASE64" | base64 -d)" +fi + +if is_pr; then + tc_set_env CHECKS_REPORTER_ACTIVE true + + # These can be removed once we're not supporting Jenkins and TeamCity at the same time + # These are primarily used by github checks reporter and can be configured via /github_checks_api.json + tc_set_env ghprbGhRepository "elastic/kibana" # TODO? + tc_set_env ghprbActualCommit "$GITHUB_PR_TRIGGERED_SHA" + tc_set_env BUILD_URL "$TEAMCITY_BUILD_URL" +else + tc_set_env CHECKS_REPORTER_ACTIVE false +fi + +tc_set_env FLEET_PACKAGE_REGISTRY_PORT 6104 # Any unused port is fine, used by ingest manager tests + +if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then + echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" + tc_set_env DETECT_CHROMEDRIVER_VERSION true + tc_set_env CHROMEDRIVER_FORCE_DOWNLOAD true +else + echo "Chrome not detected, installing default chromedriver binary for the package version" +fi diff --git a/.ci/teamcity/setup_node.sh b/.ci/teamcity/setup_node.sh new file mode 100755 index 00000000000000..b805a2aa6fe62c --- /dev/null +++ b/.ci/teamcity/setup_node.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/util.sh" + +tc_start_block "Setup Node" + +tc_set_env NODE_VERSION "$(cat "$KIBANA_DIR/.node-version")" +tc_set_env NODE_DIR "$CACHE_DIR/node/$NODE_VERSION" +tc_set_env NODE_BIN_DIR "$NODE_DIR/bin" +tc_set_env YARN_OFFLINE_CACHE "$CACHE_DIR/yarn-offline-cache" + +if [[ ! -d "$NODE_DIR" ]]; then + nodeUrl="https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz" + + echo "node.js v$NODE_VERSION not found at $NODE_DIR, downloading from $nodeUrl" + + mkdir -p "$NODE_DIR" + curl --silent -L "$nodeUrl" | tar -xz -C "$NODE_DIR" --strip-components=1 +else + echo "node.js v$NODE_VERSION already installed to $NODE_DIR, re-using" + ls -alh "$NODE_BIN_DIR" +fi + +tc_set_env PATH "$NODE_BIN_DIR:$PATH" + +tc_end_block "Setup Node" +tc_start_block "Setup Yarn" + +tc_set_env YARN_VERSION "$(node -e "console.log(String(require('./package.json').engines.yarn || '').replace(/^[^\d]+/,''))")" + +if [[ ! $(which yarn) || $(yarn --version) != "$YARN_VERSION" ]]; then + npm install -g "yarn@^${YARN_VERSION}" +fi + +yarn config set yarn-offline-mirror "$YARN_OFFLINE_CACHE" + +tc_set_env YARN_GLOBAL_BIN "$(yarn global bin)" +tc_set_env PATH "$PATH:$YARN_GLOBAL_BIN" + +tc_end_block "Setup Yarn" diff --git a/.ci/teamcity/tests/mocha.sh b/.ci/teamcity/tests/mocha.sh new file mode 100755 index 00000000000000..ea6c43c39e3978 --- /dev/null +++ b/.ci/teamcity/tests/mocha.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:mocha diff --git a/.ci/teamcity/tests/test_hardening.sh b/.ci/teamcity/tests/test_hardening.sh new file mode 100755 index 00000000000000..21ee68e5ade700 --- /dev/null +++ b/.ci/teamcity/tests/test_hardening.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:test_hardening diff --git a/.ci/teamcity/tests/test_projects.sh b/.ci/teamcity/tests/test_projects.sh new file mode 100755 index 00000000000000..3feaa821424e14 --- /dev/null +++ b/.ci/teamcity/tests/test_projects.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +yarn run grunt run:test_projects diff --git a/.ci/teamcity/tests/xpack_list_cyclic_dependency.sh b/.ci/teamcity/tests/xpack_list_cyclic_dependency.sh new file mode 100755 index 00000000000000..39f79f94744c70 --- /dev/null +++ b/.ci/teamcity/tests/xpack_list_cyclic_dependency.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +cd x-pack +checks-reporter-with-killswitch "X-Pack List cyclic dependency test" node plugins/lists/scripts/check_circular_deps diff --git a/.ci/teamcity/tests/xpack_siem_cyclic_dependency.sh b/.ci/teamcity/tests/xpack_siem_cyclic_dependency.sh new file mode 100755 index 00000000000000..e3829c961fac8a --- /dev/null +++ b/.ci/teamcity/tests/xpack_siem_cyclic_dependency.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +cd x-pack +checks-reporter-with-killswitch "X-Pack SIEM cyclic dependency test" node plugins/security_solution/scripts/check_circular_deps diff --git a/.ci/teamcity/util.sh b/.ci/teamcity/util.sh new file mode 100755 index 00000000000000..fe1afdf04c54c1 --- /dev/null +++ b/.ci/teamcity/util.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +tc_escape() { + escaped="$1" + + # See https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+values + + escaped="$(echo "$escaped" | sed -z 's/|/||/g')" + escaped="$(echo "$escaped" | sed -z "s/'/|'/g")" + escaped="$(echo "$escaped" | sed -z 's/\[/|\[/g')" + escaped="$(echo "$escaped" | sed -z 's/\]/|\]/g')" + escaped="$(echo "$escaped" | sed -z 's/\n/|n/g')" + escaped="$(echo "$escaped" | sed -z 's/\r/|r/g')" + + echo "$escaped" +} + +# Sets up an environment variable locally, and also makes it available for subsequent steps in the build +# NOTE: env vars set up this way will be visible in the UI when logged in unless you set them up as blank password parameters ahead of time. +tc_set_env() { + export "$1"="$2" + echo "##teamcity[setParameter name='env.$1' value='$(tc_escape "$2")']" +} + +verify_no_git_changes() { + RED='\033[0;31m' + C_RESET='\033[0m' # Reset color + + "$@" + + GIT_CHANGES="$(git ls-files --modified)" + if [ "$GIT_CHANGES" ]; then + echo -e "\n${RED}ERROR: '$*' caused changes to the following files:${C_RESET}\n" + echo -e "$GIT_CHANGES\n" + exit 1 + fi +} + +tc_start_block() { + echo "##teamcity[blockOpened name='$1']" +} + +tc_end_block() { + echo "##teamcity[blockClosed name='$1']" +} + +checks-reporter-with-killswitch() { + if [ "$CHECKS_REPORTER_ACTIVE" == "true" ] ; then + yarn run github-checks-reporter "$@" + else + arguments=("$@"); + "${arguments[@]:1}"; + fi +} + +is_pr() { + [[ "${GITHUB_PR_NUMBER-}" ]] && return + false +} + +# This function is specifcally for retrying test runner steps one time +# A different solution should be used for retrying general steps (e.g. bootstrap) +tc_retry() { + tc_start_block "Retryable Step - Attempt #1" + "$@" || { + tc_end_block "Retryable Step - Attempt #1" + tc_start_block "Retryable Step - Attempt #2" + >&2 echo "First attempt failed. Retrying $*" + if "$@"; then + echo 'Second attempt successful' + echo "##teamcity[buildStatus status='SUCCESS' text='{build.status.text} with a flaky failure']" + echo "##teamcity[setParameter name='elastic.build.flaky' value='true']" + tc_end_block "Retryable Step - Attempt #2" + else + status="$?" + tc_end_block "Retryable Step - Attempt #2" + return "$status" + fi + } + tc_end_block "Retryable Step - Attempt #1" +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5b43f9883a2c1f..93d49dc18d417a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -162,6 +162,8 @@ /src/cli/keystore/ @elastic/kibana-operations /src/legacy/server/warnings/ @elastic/kibana-operations /.ci/es-snapshots/ @elastic/kibana-operations +/.ci/teamcity/ @elastic/kibana-operations +/.teamcity/ @elastic/kibana-operations /vars/ @elastic/kibana-operations #CC# /packages/kbn-expect/ @elastic/kibana-operations diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 96284345d16313..d9d2d6d1ddb8b5 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -11,7 +11,7 @@ jobs: uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: - issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}]' + issue-mappings: '[{"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}]' ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.teamcity/.editorconfig b/.teamcity/.editorconfig new file mode 100644 index 00000000000000..db789a8c72de1a --- /dev/null +++ b/.teamcity/.editorconfig @@ -0,0 +1,4 @@ +[*.{kt,kts}] +disabled_rules=no-wildcard-imports +indent_size=2 +kotlin_imports_layout=idea diff --git a/.teamcity/Kibana.png b/.teamcity/Kibana.png new file mode 100644 index 00000000000000..c8f78f4575965f Binary files /dev/null and b/.teamcity/Kibana.png differ diff --git a/.teamcity/README.md b/.teamcity/README.md new file mode 100644 index 00000000000000..77c0bc5bc4cd31 --- /dev/null +++ b/.teamcity/README.md @@ -0,0 +1,156 @@ +# Kibana TeamCity + +## Implemented so far + +- Project configuration with ability to provide configuration values that are unique per TeamCity instance (e.g. dev vs prod) +- Read-only configuration (no editing through the UI) +- Secrets stored in TeamCity outside of source control +- Setting secret environment variables (they get filtered from console if output on accident) +- GCP agent configurations + - One-time use agents + - Multiple agents configured, of different sizes (cpu, memory) + - Require specific agents per build configuration +- Unit testable DSL code +- Build artifact generation and consumption +- DSL Extensions of various kinds to easily share common configuration between build configurations in the same repo +- Barebones Slack notifications via plugin +- Dynamically creating environment variables / secrets at runtime for subsequent steps +- "Baseline CI" job that runs a subset of CI for every commit +- "Hourly CI" job that runs full CI hourly, if changes are detected. Re-uses builds that ran during "Baseline CI" for same commit +- Performance monitoring enabled for all jobs +- Jobs with multiple VCS roots (Kibana + Elasticsearch) +- GCS uploading using service account key file and gsutil +- Job that has a version string as an "output", rather than an artifact/file, with consumption in a different job +- Clone a list of jobs and modify dependencies/configuration for a second pipeline +- Promote/deploy a built artifact through the UI by selecting previously built artifact (or automatically build a new one and deploy if successful) +- Custom Build IDs using service messages + +## Pull Requests + +The `Pull Request` feature in TeamCity: + +- Automatically discovers pull request branches in GitHub + - Option to filter by contributor type (members of same org, org+external contributor, everyone) + - Option to filter by target branch (e.g. only discover Pull Requests targeting master) + - Works by essentially modifying the VCS root branch spec (so you should NOT add anything related to PRs to branch spec if you are using this) + - Draft PRs do get discovered +- Adds some Pull Request information to build overview pages +- Adds a few parameters available to build configurations: + - teamcity.pullRequest.number + - teamcity.pullRequest.title + - teamcity.pullRequest.source.branch + - teamcity.pullRequest.target.branch + - (Notice that source owner is not available - there's no information for forks) +- Requires a token for API interaction + +That's it. There's no interaction with labels/comments/etc. Triggering is handled via the standard triggering options. + +So, if you only want to: + +- Build on new commit (e.g. not via comment) or via the TeamCity UI +- Start builds for users not covered by the filter options using the TeamCity UI + +The Pull Request feature may be enough to cover your needs. Otherwise, you'll need something additional (an external bot, or a new teamcity plugin, etc). + +### Other PR notes + +- TeamCity doesn't have the ability to cancel currently-running builds when a new commit is pushed +- TeamCity does not add fork information (e.g. the owner) to build configuration parameters +- Builds CAN be triggered for branches not yet discovered + - You can turn off discovery altogether, and a branch will still be build-able. When triggered externally, it will show up in the UI and build. + +How to [trigger a build via API](https://www.jetbrains.com/help/teamcity/rest-api-reference.html#Triggering+a+Build): + +``` +POST https://teamcity-server/app/rest/buildQueue + + + + +``` + +and with additional properties: + +``` + + + + + + + +``` + +## Kibana Builds + +### Baseline CI + +- Generates baseline metrics needed for PR comparisons +- Only runs OSS and default builds, and generates default saved object field metrics +- Runs for each commit (each build should build a single commit) + +### Full CI + +- Runs everything in CI - all tests and builds +- Re-uses builds from Baseline CI if they are finished or in-progress +- Not generally triggered directly, is triggered by other jobs + +### Hourly CI + +- Triggers every hour and groups up all changes since the last run +- Runs whatever is in `Full CI` + +### Pull Request CI + +- Kibana TeamCity PR bot triggers this build for PRs (new commits, trigger comments) +- Sets many PR related parameters/env vars, then runs `Full CI` + +![Diagram](Kibana.png) + +### ES Snapshot Verification + +Build Configurations: + +- Build Snapshot +- Test Builds (e.g. OSS CI Group 1, Default CI Group 3, etc) +- Verify Snapshot +- Promote Snapshot +- Immediately Promote Snapshot + +Desires: + +- Build ES snapshot on a daily basis, run E2E tests against it, promote when successful +- Ability to easily promote old builds that have been verified +- Ability to run verification without promoting it + +#### Build Snapshot + +- checks out both Kibana and ES codebases +- builds ES artifacts +- uses scripts from Kibana repo to create JSON manifest and assemble snapshot files +- uploads artifacts to GCS +- sets parameters via service message that contains the snapshot URL, ID, version so they can be consumed by downstream jobs +- triggers on timer, once per day + +#### Test Builds + +- builds are clones of all "essential ci" functional and integration tests with irrelevant features disabled + - they are clones because runs of this build and runs of the essential ci versions for the same commit hash mean different things +- snapshot dependency on `Build Elasticsearch Snapshot` is added to clones +- set `env.ES_SNAPSHOT_MANIFEST` = `dep..ES_SNAPSHOT_MANIFEST` to "consume" the built artifact + +#### Verify Snapshot + +- composite build that contains all of the cloned test builds + +#### Promote Snapshot + +- snapshot dependency on `Build Snapshot` and `Verify Snapshot` +- uses scripts from Kibana repo to promote elasticsearch snapshot from `Build Snapshot` by updating manifest files in GCS +- triggers whenever a build of `Verify Snapshot` completes successfully + +#### Immediately Promote Snapshot + +- snapshot dependency only on `Build Snapshot` +- same as `Promote Snapshot` but skips testing +- can only be triggered manually diff --git a/.teamcity/pom.xml b/.teamcity/pom.xml new file mode 100644 index 00000000000000..5fa068d0a92e03 --- /dev/null +++ b/.teamcity/pom.xml @@ -0,0 +1,128 @@ + + + + + 4.0.0 + Kibana Teamcity Config DSL Script + org.elastic.kibana + kibana-teamcity-dsl + 1.0-SNAPSHOT + + + org.jetbrains.teamcity + configs-dsl-kotlin-parent + 1.0-SNAPSHOT + + + + + jetbrains-all + https://download.jetbrains.com/teamcity-repository + + true + + + + teamcity-server + https://ci.elastic.dev/app/dsl-plugins-repository + + true + + + + + + + JetBrains + https://download.jetbrains.com/teamcity-repository + + + + + tests + src + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + + compile + process-sources + + compile + + + + test-compile + process-test-sources + + test-compile + + + + + + org.jetbrains.teamcity + teamcity-configs-maven-plugin + ${teamcity.dsl.version} + + kotlin + target/generated-configs + + + + + + + + org.jetbrains.teamcity + configs-dsl-kotlin + ${teamcity.dsl.version} + compile + + + org.jetbrains.teamcity + configs-dsl-kotlin-plugins + 1.0-SNAPSHOT + pom + compile + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + compile + + + org.jetbrains.kotlin + kotlin-script-runtime + ${kotlin.version} + compile + + + junit + junit + 4.13 + + + diff --git a/.teamcity/settings.kts b/.teamcity/settings.kts new file mode 100644 index 00000000000000..ec1b1c6eb94ef6 --- /dev/null +++ b/.teamcity/settings.kts @@ -0,0 +1,12 @@ +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import projects.Kibana +import projects.KibanaConfiguration + +version = "2020.1" + +val config = KibanaConfiguration { + agentNetwork = DslContext.getParameter("agentNetwork", "teamcity") + agentSubnet = DslContext.getParameter("agentSubnet", "teamcity") +} + +project(Kibana(config)) diff --git a/.teamcity/src/Extensions.kt b/.teamcity/src/Extensions.kt new file mode 100644 index 00000000000000..120b333d43e724 --- /dev/null +++ b/.teamcity/src/Extensions.kt @@ -0,0 +1,169 @@ +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.notifications +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.ScriptBuildStep +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import jetbrains.buildServer.configs.kotlin.v2019_2.ui.insert +import projects.kibanaConfiguration + +fun BuildFeatures.junit(dirs: String = "target/**/TEST-*.xml") { + feature { + type = "xml-report-plugin" + param("xmlReportParsing.reportType", "junit") + param("xmlReportParsing.reportDirs", dirs) + } +} + +fun ProjectFeatures.kibanaAgent(init: ProjectFeature.() -> Unit) { + feature { + type = "CloudImage" + param("network", kibanaConfiguration.agentNetwork) + param("subnet", kibanaConfiguration.agentSubnet) + param("growingId", "true") + param("agent_pool_id", "-2") + param("preemptible", "false") + param("sourceProject", "elastic-images-prod") + param("sourceImageFamily", "elastic-kibana-ci-ubuntu-1804-lts") + param("zone", "us-central1-a") + param("profileId", "kibana") + param("diskType", "pd-ssd") + param("machineCustom", "false") + param("maxInstances", "200") + param("imageType", "ImageFamily") + param("diskSizeGb", "75") // TODO + init() + } +} + +fun ProjectFeatures.kibanaAgent(size: String, init: ProjectFeature.() -> Unit = {}) { + kibanaAgent { + id = "KIBANA_STANDARD_$size" + param("source-id", "kibana-standard-$size-") + param("machineType", "n2-standard-$size") + init() + } +} + +fun BuildType.kibanaAgent(size: String) { + requirements { + startsWith("teamcity.agent.name", "kibana-standard-$size-", "RQ_AGENT_NAME") + } +} + +fun BuildType.kibanaAgent(size: Int) { + kibanaAgent(size.toString()) +} + +val testArtifactRules = """ + target/kibana-* + target/test-metrics/* + target/kibana-security-solution/**/*.png + target/junit/**/* + target/test-suites-ci-plan.json + test/**/screenshots/session/*.png + test/**/screenshots/failure/*.png + test/**/screenshots/diff/*.png + test/functional/failure_debug/html/*.html + x-pack/test/**/screenshots/session/*.png + x-pack/test/**/screenshots/failure/*.png + x-pack/test/**/screenshots/diff/*.png + x-pack/test/functional/failure_debug/html/*.html + x-pack/test/functional/apps/reporting/reports/session/*.pdf + """.trimIndent() + +fun BuildType.addTestSettings() { + artifactRules += "\n" + testArtifactRules + steps { + failedTestReporter() + } + features { + junit() + } +} + +fun BuildType.addSlackNotifications(to: String = "#kibana-teamcity-testing") { + params { + param("elastic.slack.enabled", "true") + param("elastic.slack.channels", to) + } +} + +fun BuildType.dependsOn(buildType: BuildType, init: SnapshotDependency.() -> Unit = {}) { + dependencies { + snapshot(buildType) { + reuseBuilds = ReuseBuilds.SUCCESSFUL + onDependencyCancel = FailureAction.ADD_PROBLEM + onDependencyFailure = FailureAction.ADD_PROBLEM + synchronizeRevisions = true + init() + } + } +} + +fun BuildType.dependsOn(vararg buildTypes: BuildType, init: SnapshotDependency.() -> Unit = {}) { + buildTypes.forEach { dependsOn(it, init) } +} + +fun BuildSteps.failedTestReporter(init: ScriptBuildStep.() -> Unit = {}) { + script { + name = "Failed Test Reporter" + scriptContent = + """ + #!/bin/bash + node scripts/report_failed_tests + """.trimIndent() + executionMode = BuildStep.ExecutionMode.RUN_ON_FAILURE + init() + } +} + +// Note: This is currently only used for tests and has a retry in it for flaky tests. +// The retry should be refactored if runbld is ever needed for other tasks. +fun BuildSteps.runbld(stepName: String, script: String) { + script { + name = stepName + + // The indentation for this string is like this to ensure 100% that the RUNBLD-SCRIPT heredoc termination will not have spaces at the beginning + scriptContent = +"""#!/bin/bash + +set -euo pipefail + +source .ci/teamcity/util.sh + +branchName="${'$'}GIT_BRANCH" +branchName="${'$'}{branchName#refs\/heads\/}" + +if [[ "${'$'}{GITHUB_PR_NUMBER-}" ]]; then + branchName=pull-request +fi + +project=kibana +if [[ "${'$'}{ES_SNAPSHOT_MANIFEST-}" ]]; then + project=kibana-es-snapshot-verify +fi + +# These parameters are only for runbld reporting +export JENKINS_HOME="${'$'}HOME" +export BUILD_URL="%teamcity.serverUrl%/build/%teamcity.build.id%" +export branch_specifier=${'$'}branchName +export NODE_LABELS='teamcity' +export BUILD_NUMBER="%build.number%" +export EXECUTOR_NUMBER='' +export NODE_NAME='' + +export OLD_PATH="${'$'}PATH" + +file=${'$'}(mktemp) + +( +cat < ${'$'}file + +tc_retry /usr/local/bin/runbld -d "${'$'}(pwd)" --job-name="elastic+${'$'}project+${'$'}branchName" ${'$'}file +""" + } +} diff --git a/.teamcity/src/builds/BaselineCi.kt b/.teamcity/src/builds/BaselineCi.kt new file mode 100644 index 00000000000000..ae316960acf89f --- /dev/null +++ b/.teamcity/src/builds/BaselineCi.kt @@ -0,0 +1,38 @@ +package builds + +import addSlackNotifications +import builds.default.DefaultBuild +import builds.default.DefaultSavedObjectFieldMetrics +import builds.oss.OssBuild +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.FailureAction +import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs +import templates.KibanaTemplate + +object BaselineCi : BuildType({ + id("Baseline_CI") + name = "Baseline CI" + description = "Runs builds, saved object field metrics for every commit" + type = Type.COMPOSITE + paused = true + + templates(KibanaTemplate) + + triggers { + vcs { + branchFilter = "refs/heads/master_teamcity" +// perCheckinTriggering = true // TODO re-enable this later, it wreaks havoc when I merge upstream + } + } + + dependsOn( + OssBuild, + DefaultBuild, + DefaultSavedObjectFieldMetrics + ) { + onDependencyCancel = FailureAction.ADD_PROBLEM + } + + addSlackNotifications() +}) diff --git a/.teamcity/src/builds/Checks.kt b/.teamcity/src/builds/Checks.kt new file mode 100644 index 00000000000000..1228ea4d94f4c9 --- /dev/null +++ b/.teamcity/src/builds/Checks.kt @@ -0,0 +1,37 @@ +package builds + +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import kibanaAgent + +object Checks : BuildType({ + name = "Checks" + description = "Executes Various Checks" + + kibanaAgent(4) + + val checkScripts = mapOf( + "Check Telemetry Schema" to ".ci/teamcity/checks/telemetry.sh", + "Check TypeScript Projects" to ".ci/teamcity/checks/ts_projects.sh", + "Check File Casing" to ".ci/teamcity/checks/file_casing.sh", + "Check Licenses" to ".ci/teamcity/checks/licenses.sh", + "Verify NOTICE" to ".ci/teamcity/checks/verify_notice.sh", + "Test Hardening" to ".ci/teamcity/checks/test_hardening.sh", + "Check Types" to ".ci/teamcity/checks/type_check.sh", + "Check Doc API Changes" to ".ci/teamcity/checks/doc_api_changes.sh", + "Check Bundle Limits" to ".ci/teamcity/checks/bundle_limits.sh", + "Check i18n" to ".ci/teamcity/checks/i18n.sh" + ) + + steps { + for (checkScript in checkScripts) { + script { + name = checkScript.key + scriptContent = """ + #!/bin/bash + ${checkScript.value} + """.trimIndent() + } + } + } +}) diff --git a/.teamcity/src/builds/FullCi.kt b/.teamcity/src/builds/FullCi.kt new file mode 100644 index 00000000000000..7f19304428d7e1 --- /dev/null +++ b/.teamcity/src/builds/FullCi.kt @@ -0,0 +1,30 @@ +package builds + +import builds.default.* +import builds.oss.* +import builds.test.AllTests +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType + +object FullCi : BuildType({ + id("Full_CI") + name = "Full CI" + description = "Runs everything in CI. For tracked branches and PRs." + type = Type.COMPOSITE + + dependsOn( + Lint, + Checks, + AllTests, + OssBuild, + OssAccessibility, + OssPluginFunctional, + OssCiGroups, + OssFirefox, + DefaultBuild, + DefaultCiGroups, + DefaultFirefox, + DefaultAccessibility, + DefaultSecuritySolution + ) +}) diff --git a/.teamcity/src/builds/HourlyCi.kt b/.teamcity/src/builds/HourlyCi.kt new file mode 100644 index 00000000000000..605a22f0129763 --- /dev/null +++ b/.teamcity/src/builds/HourlyCi.kt @@ -0,0 +1,34 @@ +package builds + +import addSlackNotifications +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.FailureAction +import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.schedule + +object HourlyCi : BuildType({ + id("Hourly_CI") + name = "Hourly CI" + description = "Runs everything in CI, hourly" + type = Type.COMPOSITE + + triggers { + schedule { + schedulingPolicy = cron { + hours = "*" + minutes = "0" + } + branchFilter = "refs/heads/master_teamcity" + triggerBuild = always() + withPendingChangesOnly = true + } + } + + dependsOn( + FullCi + ) { + onDependencyCancel = FailureAction.ADD_PROBLEM + } + + addSlackNotifications() +}) diff --git a/.teamcity/src/builds/Lint.kt b/.teamcity/src/builds/Lint.kt new file mode 100644 index 00000000000000..0b3b3b013b5ec3 --- /dev/null +++ b/.teamcity/src/builds/Lint.kt @@ -0,0 +1,33 @@ +package builds + +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import kibanaAgent + +object Lint : BuildType({ + name = "Lint" + description = "Executes Linting, such as eslint and sasslint" + + kibanaAgent(2) + + steps { + script { + name = "Sasslint" + + scriptContent = + """ + #!/bin/bash + yarn run grunt run:sasslint + """.trimIndent() + } + + script { + name = "ESLint" + scriptContent = + """ + #!/bin/bash + yarn run grunt run:eslint + """.trimIndent() + } + } +}) diff --git a/.teamcity/src/builds/PullRequestCi.kt b/.teamcity/src/builds/PullRequestCi.kt new file mode 100644 index 00000000000000..d3eb697981ce7c --- /dev/null +++ b/.teamcity/src/builds/PullRequestCi.kt @@ -0,0 +1,78 @@ +package builds + +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.AbsoluteId +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.PullRequests +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.commitStatusPublisher +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.pullRequests +import vcs.Kibana + +object PullRequestCi : BuildType({ + id = AbsoluteId("Kibana_PullRequest_CI") + name = "Pull Request CI" + type = Type.COMPOSITE + + buildNumberPattern = "%build.counter%-%env.GITHUB_PR_OWNER%-%env.GITHUB_PR_BRANCH%" + + vcs { + root(Kibana) + checkoutDir = "kibana" + + branchFilter = "+:pull/*" + excludeDefaultBranchChanges = true + } + + val prAllowedList = listOf( + "brianseeders", + "alexwizp", + "barlowm", + "DziyanaDzeraviankina", + "maryia-lapata", + "renovate[bot]", + "sulemanof", + "VladLasitsa" + ) + + params { + param("elastic.pull_request.enabled", "true") + param("elastic.pull_request.target_branch", "master_teamcity") + param("elastic.pull_request.allow_org_users", "true") + param("elastic.pull_request.allowed_repo_permissions", "admin,write") + param("elastic.pull_request.allowed_list", prAllowedList.joinToString(",")) + param("elastic.pull_request.cancel_in_progress_builds_on_update", "true") + + // These params should get filled in by the app that triggers builds + param("env.GITHUB_PR_TARGET_BRANCH", "") + param("env.GITHUB_PR_NUMBER", "") + param("env.GITHUB_PR_OWNER", "") + param("env.GITHUB_PR_REPO", "") + param("env.GITHUB_PR_BRANCH", "") + param("env.GITHUB_PR_TRIGGERED_SHA", "") + param("env.GITHUB_PR_LABELS", "") + param("env.GITHUB_PR_TRIGGER_COMMENT", "") + + param("reverse.dep.*.env.GITHUB_PR_TARGET_BRANCH", "") + param("reverse.dep.*.env.GITHUB_PR_NUMBER", "") + param("reverse.dep.*.env.GITHUB_PR_OWNER", "") + param("reverse.dep.*.env.GITHUB_PR_REPO", "") + param("reverse.dep.*.env.GITHUB_PR_BRANCH", "") + param("reverse.dep.*.env.GITHUB_PR_TRIGGERED_SHA", "") + param("reverse.dep.*.env.GITHUB_PR_LABELS", "") + param("reverse.dep.*.env.GITHUB_PR_TRIGGER_COMMENT", "") + } + + features { + commitStatusPublisher { + vcsRootExtId = "${Kibana.id}" + publisher = github { + githubUrl = "https://api.github.com" + authType = personalToken { + token = "credentialsJSON:07d22002-12de-4627-91c3-672bdb23b55b" + } + } + } + } + + dependsOn(FullCi) +}) diff --git a/.teamcity/src/builds/default/DefaultAccessibility.kt b/.teamcity/src/builds/default/DefaultAccessibility.kt new file mode 100755 index 00000000000000..f0a9c60cf3e450 --- /dev/null +++ b/.teamcity/src/builds/default/DefaultAccessibility.kt @@ -0,0 +1,12 @@ +package builds.default + +import runbld + +object DefaultAccessibility : DefaultFunctionalBase({ + id("DefaultAccessibility") + name = "Accessibility" + + steps { + runbld("Default Accessibility", "./.ci/teamcity/default/accessibility.sh") + } +}) diff --git a/.teamcity/src/builds/default/DefaultBuild.kt b/.teamcity/src/builds/default/DefaultBuild.kt new file mode 100644 index 00000000000000..f4683e6cf0c1a0 --- /dev/null +++ b/.teamcity/src/builds/default/DefaultBuild.kt @@ -0,0 +1,56 @@ +package builds.default + +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.Dependencies +import jetbrains.buildServer.configs.kotlin.v2019_2.FailureAction +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script + +object DefaultBuild : BuildType({ + name = "Build Default" + description = "Generates Default Build Distribution artifact" + + artifactRules = """ + +:install/kibana/**/* => kibana-default.tar.gz + target/kibana-* + +:src/**/target/public/**/* => kibana-default-plugins.tar.gz!/src/ + +:x-pack/plugins/**/target/public/**/* => kibana-default-plugins.tar.gz!/x-pack/plugins/ + +:x-pack/test/**/target/public/**/* => kibana-default-plugins.tar.gz!/x-pack/test/ + +:examples/**/target/public/**/* => kibana-default-plugins.tar.gz!/examples/ + +:test/**/target/public/**/* => kibana-default-plugins.tar.gz!/test/ + """.trimIndent() + + requirements { + startsWith("teamcity.agent.name", "kibana-c2-16-", "RQ_AGENT_NAME") + } + + steps { + script { + name = "Build Default Distribution" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/default/build.sh + """.trimIndent() + } + } +}) + +fun Dependencies.defaultBuild(rules: String = "+:kibana-default.tar.gz!** => ../build/kibana-build-default") { + dependency(DefaultBuild) { + snapshot { + onDependencyFailure = FailureAction.FAIL_TO_START + onDependencyCancel = FailureAction.FAIL_TO_START + } + + artifacts { + artifactRules = rules + } + } +} + +fun Dependencies.defaultBuildWithPlugins() { + defaultBuild(""" + +:kibana-default.tar.gz!** => ../build/kibana-build-default + +:kibana-default-plugins.tar.gz!** + """.trimIndent()) +} diff --git a/.teamcity/src/builds/default/DefaultCiGroup.kt b/.teamcity/src/builds/default/DefaultCiGroup.kt new file mode 100755 index 00000000000000..7dbe9cd0ba84c4 --- /dev/null +++ b/.teamcity/src/builds/default/DefaultCiGroup.kt @@ -0,0 +1,15 @@ +package builds.default + +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import runbld + +class DefaultCiGroup(val ciGroup: Int = 0, init: BuildType.() -> Unit = {}) : DefaultFunctionalBase({ + id("DefaultCiGroup_$ciGroup") + name = "CI Group $ciGroup" + + steps { + runbld("Default CI Group $ciGroup", "./.ci/teamcity/default/ci_group.sh $ciGroup") + } + + init() +}) diff --git a/.teamcity/src/builds/default/DefaultCiGroups.kt b/.teamcity/src/builds/default/DefaultCiGroups.kt new file mode 100644 index 00000000000000..6f1d45598c92eb --- /dev/null +++ b/.teamcity/src/builds/default/DefaultCiGroups.kt @@ -0,0 +1,15 @@ +package builds.default + +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType + +const val DEFAULT_CI_GROUP_COUNT = 10 +val defaultCiGroups = (1..DEFAULT_CI_GROUP_COUNT).map { DefaultCiGroup(it) } + +object DefaultCiGroups : BuildType({ + id("Default_CIGroups_Composite") + name = "CI Groups" + type = Type.COMPOSITE + + dependsOn(*defaultCiGroups.toTypedArray()) +}) diff --git a/.teamcity/src/builds/default/DefaultFirefox.kt b/.teamcity/src/builds/default/DefaultFirefox.kt new file mode 100755 index 00000000000000..2429967d249392 --- /dev/null +++ b/.teamcity/src/builds/default/DefaultFirefox.kt @@ -0,0 +1,12 @@ +package builds.default + +import runbld + +object DefaultFirefox : DefaultFunctionalBase({ + id("DefaultFirefox") + name = "Firefox" + + steps { + runbld("Default Firefox", "./.ci/teamcity/default/firefox.sh") + } +}) diff --git a/.teamcity/src/builds/default/DefaultFunctionalBase.kt b/.teamcity/src/builds/default/DefaultFunctionalBase.kt new file mode 100644 index 00000000000000..d8124bd8521c0a --- /dev/null +++ b/.teamcity/src/builds/default/DefaultFunctionalBase.kt @@ -0,0 +1,19 @@ +package builds.default + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType + +open class DefaultFunctionalBase(init: BuildType.() -> Unit = {}) : BuildType({ + params { + param("env.KBN_NP_PLUGINS_BUILT", "true") + } + + dependencies { + defaultBuildWithPlugins() + } + + init() + + addTestSettings() +}) + diff --git a/.teamcity/src/builds/default/DefaultSavedObjectFieldMetrics.kt b/.teamcity/src/builds/default/DefaultSavedObjectFieldMetrics.kt new file mode 100644 index 00000000000000..61505d4757faaa --- /dev/null +++ b/.teamcity/src/builds/default/DefaultSavedObjectFieldMetrics.kt @@ -0,0 +1,28 @@ +package builds.default + +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script + +object DefaultSavedObjectFieldMetrics : BuildType({ + id("DefaultSavedObjectFieldMetrics") + name = "Default Saved Object Field Metrics" + + params { + param("env.KBN_NP_PLUGINS_BUILT", "true") + } + + steps { + script { + name = "Default Saved Object Field Metrics" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/default/saved_object_field_metrics.sh + """.trimIndent() + } + } + + dependencies { + defaultBuild() + } +}) diff --git a/.teamcity/src/builds/default/DefaultSecuritySolution.kt b/.teamcity/src/builds/default/DefaultSecuritySolution.kt new file mode 100755 index 00000000000000..1c3b85257c28a2 --- /dev/null +++ b/.teamcity/src/builds/default/DefaultSecuritySolution.kt @@ -0,0 +1,15 @@ +package builds.default + +import addTestSettings +import runbld + +object DefaultSecuritySolution : DefaultFunctionalBase({ + id("DefaultSecuritySolution") + name = "Security Solution" + + steps { + runbld("Default Security Solution", "./.ci/teamcity/default/security_solution.sh") + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/es_snapshots/Build.kt b/.teamcity/src/builds/es_snapshots/Build.kt new file mode 100644 index 00000000000000..d0c849ff5f9964 --- /dev/null +++ b/.teamcity/src/builds/es_snapshots/Build.kt @@ -0,0 +1,84 @@ +package builds.es_snapshots + +import addSlackNotifications +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import vcs.Elasticsearch +import vcs.Kibana + +object ESSnapshotBuild : BuildType({ + name = "Build Snapshot" + paused = true + + requirements { + startsWith("teamcity.agent.name", "kibana-c2-16-", "RQ_AGENT_NAME") + } + + vcs { + root(Kibana, "+:. => kibana") + root(Elasticsearch, "+:. => elasticsearch") + checkoutDir = "" + } + + params { + param("env.ELASTICSEARCH_BRANCH", "%vcsroot.${Elasticsearch.id.toString()}.branch%") + param("env.ELASTICSEARCH_GIT_COMMIT", "%build.vcs.number.${Elasticsearch.id.toString()}%") + + param("env.GOOGLE_APPLICATION_CREDENTIALS", "%teamcity.build.workingDir%/gcp-credentials.json") + password("env.GOOGLE_APPLICATION_CREDENTIALS_JSON", "credentialsJSON:6e0acb7c-f89c-4225-84b8-4fc102f1a5ef", display = ParameterDisplay.HIDDEN) + } + + steps { + script { + name = "Setup Environment" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_env.sh + """.trimIndent() + } + + script { + name = "Setup Node and Yarn" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_node.sh + """.trimIndent() + } + + script { + name = "Build Elasticsearch Distribution" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/es_snapshots/build.sh + """.trimIndent() + } + + script { + name = "Setup Google Cloud Credentials" + scriptContent = + """#!/bin/bash + echo "${"$"}GOOGLE_APPLICATION_CREDENTIALS_JSON" > "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + gcloud auth activate-service-account --key-file "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + """.trimIndent() + } + + script { + name = "Create Snapshot Manifest" + scriptContent = + """#!/bin/bash + cd kibana + node ./.ci/teamcity/es_snapshots/create_manifest.js "$(cd ../es-build && pwd)" + """.trimIndent() + } + } + + artifactRules = "+:es-build/**/*.json" + + addSlackNotifications() +}) diff --git a/.teamcity/src/builds/es_snapshots/Promote.kt b/.teamcity/src/builds/es_snapshots/Promote.kt new file mode 100644 index 00000000000000..9303439d49f307 --- /dev/null +++ b/.teamcity/src/builds/es_snapshots/Promote.kt @@ -0,0 +1,87 @@ +package builds.es_snapshots + +import addSlackNotifications +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.finishBuildTrigger +import vcs.Kibana + +object ESSnapshotPromote : BuildType({ + name = "Promote Snapshot" + paused = true + type = Type.DEPLOYMENT + + vcs { + root(Kibana, "+:. => kibana") + checkoutDir = "" + } + + params { + param("env.ES_SNAPSHOT_MANIFEST", "${ESSnapshotBuild.depParamRefs["env.ES_SNAPSHOT_MANIFEST"]}") + param("env.GOOGLE_APPLICATION_CREDENTIALS", "%teamcity.build.workingDir%/gcp-credentials.json") + password("env.GOOGLE_APPLICATION_CREDENTIALS_JSON", "credentialsJSON:6e0acb7c-f89c-4225-84b8-4fc102f1a5ef", display = ParameterDisplay.HIDDEN) + } + + triggers { + finishBuildTrigger { + buildType = Verify.id.toString() + successfulOnly = true + } + } + + steps { + script { + name = "Setup Environment" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_env.sh + """.trimIndent() + } + + script { + name = "Setup Node and Yarn" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_node.sh + """.trimIndent() + } + + script { + name = "Setup Google Cloud Credentials" + scriptContent = + """#!/bin/bash + echo "${"$"}GOOGLE_APPLICATION_CREDENTIALS_JSON" > "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + gcloud auth activate-service-account --key-file "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + """.trimIndent() + } + + script { + name = "Promote Snapshot Manifest" + scriptContent = + """#!/bin/bash + cd kibana + node ./.ci/teamcity/es_snapshots/promote_manifest.js "${"$"}ES_SNAPSHOT_MANIFEST" + """.trimIndent() + } + } + + dependencies { + dependency(ESSnapshotBuild) { + snapshot { } + + // This is just here to allow build selection in the UI, the file isn't actually used + artifacts { + artifactRules = "manifest.json" + } + } + dependency(Verify) { + snapshot { } + } + } + + addSlackNotifications() +}) diff --git a/.teamcity/src/builds/es_snapshots/PromoteImmediate.kt b/.teamcity/src/builds/es_snapshots/PromoteImmediate.kt new file mode 100644 index 00000000000000..f80a97873b2461 --- /dev/null +++ b/.teamcity/src/builds/es_snapshots/PromoteImmediate.kt @@ -0,0 +1,79 @@ +package builds.es_snapshots + +import addSlackNotifications +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.finishBuildTrigger +import vcs.Elasticsearch +import vcs.Kibana + +object ESSnapshotPromoteImmediate : BuildType({ + name = "Immediately Promote Snapshot" + description = "Skip testing and immediately promote the selected snapshot" + paused = true + type = Type.DEPLOYMENT + + vcs { + root(Kibana, "+:. => kibana") + checkoutDir = "" + } + + params { + param("env.ES_SNAPSHOT_MANIFEST", "${ESSnapshotBuild.depParamRefs["env.ES_SNAPSHOT_MANIFEST"]}") + param("env.GOOGLE_APPLICATION_CREDENTIALS", "%teamcity.build.workingDir%/gcp-credentials.json") + password("env.GOOGLE_APPLICATION_CREDENTIALS_JSON", "credentialsJSON:6e0acb7c-f89c-4225-84b8-4fc102f1a5ef", display = ParameterDisplay.HIDDEN) + } + + steps { + script { + name = "Setup Environment" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_env.sh + """.trimIndent() + } + + script { + name = "Setup Node and Yarn" + scriptContent = + """ + #!/bin/bash + cd kibana + ./.ci/teamcity/setup_node.sh + """.trimIndent() + } + + script { + name = "Setup Google Cloud Credentials" + scriptContent = + """#!/bin/bash + echo "${"$"}GOOGLE_APPLICATION_CREDENTIALS_JSON" > "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + gcloud auth activate-service-account --key-file "${"$"}GOOGLE_APPLICATION_CREDENTIALS" + """.trimIndent() + } + + script { + name = "Promote Snapshot Manifest" + scriptContent = + """#!/bin/bash + cd kibana + node ./.ci/teamcity/es_snapshots/promote_manifest.js "${"$"}ES_SNAPSHOT_MANIFEST" + """.trimIndent() + } + } + + dependencies { + dependency(ESSnapshotBuild) { + snapshot { } + + // This is just here to allow build selection in the UI, the file isn't actually used + artifacts { + artifactRules = "manifest.json" + } + } + } + + addSlackNotifications() +}) diff --git a/.teamcity/src/builds/es_snapshots/Verify.kt b/.teamcity/src/builds/es_snapshots/Verify.kt new file mode 100644 index 00000000000000..c778814af536c4 --- /dev/null +++ b/.teamcity/src/builds/es_snapshots/Verify.kt @@ -0,0 +1,96 @@ +package builds.es_snapshots + +import builds.default.DefaultBuild +import builds.default.DefaultSecuritySolution +import builds.default.defaultCiGroups +import builds.oss.OssBuild +import builds.oss.OssPluginFunctional +import builds.oss.ossCiGroups +import builds.test.ApiServerIntegration +import builds.test.JestIntegration +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.* + +val cloneForVerify = { build: BuildType -> + val newBuild = BuildType() + build.copyTo(newBuild) + newBuild.id = AbsoluteId(build.id?.toString() + "_ES_Snapshots") + newBuild.params { + param("env.ES_SNAPSHOT_MANIFEST", "${ESSnapshotBuild.depParamRefs["env.ES_SNAPSHOT_MANIFEST"]}") + } + newBuild.dependencies { + dependency(ESSnapshotBuild) { + snapshot { + onDependencyFailure = FailureAction.FAIL_TO_START + onDependencyCancel = FailureAction.FAIL_TO_START + } + // This is just here to allow us to select a build when manually triggering a build using the UI + artifacts { + artifactRules = "manifest.json" + } + } + } + newBuild.steps.items.removeIf { it.name == "Failed Test Reporter" } + newBuild +} + +val ossBuildsToClone = listOf( + *ossCiGroups.toTypedArray(), + OssPluginFunctional +) + +val ossCloned = ossBuildsToClone.map { cloneForVerify(it) } + +val defaultBuildsToClone = listOf( + *defaultCiGroups.toTypedArray(), + DefaultSecuritySolution +) + +val defaultCloned = defaultBuildsToClone.map { cloneForVerify(it) } + +val integrationsBuildsToClone = listOf( + ApiServerIntegration, + JestIntegration +) + +val integrationCloned = integrationsBuildsToClone.map { cloneForVerify(it) } + +object OssTests : BuildType({ + id("ES_Snapshots_OSS_Tests_Composite") + name = "OSS Distro Tests" + type = Type.COMPOSITE + + dependsOn(*ossCloned.toTypedArray()) +}) + +object DefaultTests : BuildType({ + id("ES_Snapshots_Default_Tests_Composite") + name = "Default Distro Tests" + type = Type.COMPOSITE + + dependsOn(*defaultCloned.toTypedArray()) +}) + +object IntegrationTests : BuildType({ + id("ES_Snapshots_Integration_Tests_Composite") + name = "Integration Tests" + type = Type.COMPOSITE + + dependsOn(*integrationCloned.toTypedArray()) +}) + +object Verify : BuildType({ + id("ES_Snapshots_Verify_Composite") + name = "Verify Snapshot" + description = "Run all Kibana functional and integration tests using a given Elasticsearch snapshot" + type = Type.COMPOSITE + + dependsOn( + ESSnapshotBuild, + OssBuild, + DefaultBuild, + OssTests, + DefaultTests, + IntegrationTests + ) +}) diff --git a/.teamcity/src/builds/oss/OssAccessibility.kt b/.teamcity/src/builds/oss/OssAccessibility.kt new file mode 100644 index 00000000000000..8e4a7acd77b768 --- /dev/null +++ b/.teamcity/src/builds/oss/OssAccessibility.kt @@ -0,0 +1,13 @@ +package builds.oss + +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import runbld + +object OssAccessibility : OssFunctionalBase({ + id("OssAccessibility") + name = "Accessibility" + + steps { + runbld("OSS Accessibility", "./.ci/teamcity/oss/accessibility.sh") + } +}) diff --git a/.teamcity/src/builds/oss/OssBuild.kt b/.teamcity/src/builds/oss/OssBuild.kt new file mode 100644 index 00000000000000..50fd73c17ba426 --- /dev/null +++ b/.teamcity/src/builds/oss/OssBuild.kt @@ -0,0 +1,41 @@ +package builds.oss + +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import jetbrains.buildServer.configs.kotlin.v2019_2.Dependencies +import jetbrains.buildServer.configs.kotlin.v2019_2.FailureAction +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script + +object OssBuild : BuildType({ + name = "Build OSS" + description = "Generates OSS Build Distribution artifact" + + requirements { + startsWith("teamcity.agent.name", "kibana-c2-16-", "RQ_AGENT_NAME") + } + + steps { + script { + name = "Build OSS Distribution" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/oss/build.sh + """.trimIndent() + } + } + + artifactRules = "+:build/oss/kibana-build-oss/**/* => kibana-oss.tar.gz" +}) + +fun Dependencies.ossBuild(rules: String = "+:kibana-oss.tar.gz!** => ../build/kibana-build-oss") { + dependency(OssBuild) { + snapshot { + onDependencyFailure = FailureAction.FAIL_TO_START + onDependencyCancel = FailureAction.FAIL_TO_START + } + + artifacts { + artifactRules = rules + } + } +} diff --git a/.teamcity/src/builds/oss/OssCiGroup.kt b/.teamcity/src/builds/oss/OssCiGroup.kt new file mode 100644 index 00000000000000..1c188cd4c175fc --- /dev/null +++ b/.teamcity/src/builds/oss/OssCiGroup.kt @@ -0,0 +1,15 @@ +package builds.oss + +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import runbld + +class OssCiGroup(val ciGroup: Int, init: BuildType.() -> Unit = {}) : OssFunctionalBase({ + id("OssCiGroup_$ciGroup") + name = "CI Group $ciGroup" + + steps { + runbld("OSS CI Group $ciGroup", "./.ci/teamcity/oss/ci_group.sh $ciGroup") + } + + init() +}) diff --git a/.teamcity/src/builds/oss/OssCiGroups.kt b/.teamcity/src/builds/oss/OssCiGroups.kt new file mode 100644 index 00000000000000..931cca2554a24f --- /dev/null +++ b/.teamcity/src/builds/oss/OssCiGroups.kt @@ -0,0 +1,15 @@ +package builds.oss + +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType + +const val OSS_CI_GROUP_COUNT = 12 +val ossCiGroups = (1..OSS_CI_GROUP_COUNT).map { OssCiGroup(it) } + +object OssCiGroups : BuildType({ + id("OSS_CIGroups_Composite") + name = "CI Groups" + type = Type.COMPOSITE + + dependsOn(*ossCiGroups.toTypedArray()) +}) diff --git a/.teamcity/src/builds/oss/OssFirefox.kt b/.teamcity/src/builds/oss/OssFirefox.kt new file mode 100644 index 00000000000000..2db8314fa44fc5 --- /dev/null +++ b/.teamcity/src/builds/oss/OssFirefox.kt @@ -0,0 +1,12 @@ +package builds.oss + +import runbld + +object OssFirefox : OssFunctionalBase({ + id("OssFirefox") + name = "Firefox" + + steps { + runbld("OSS Firefox", "./.ci/teamcity/oss/firefox.sh") + } +}) diff --git a/.teamcity/src/builds/oss/OssFunctionalBase.kt b/.teamcity/src/builds/oss/OssFunctionalBase.kt new file mode 100644 index 00000000000000..d8189fd3589660 --- /dev/null +++ b/.teamcity/src/builds/oss/OssFunctionalBase.kt @@ -0,0 +1,18 @@ +package builds.oss + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.* + +open class OssFunctionalBase(init: BuildType.() -> Unit = {}) : BuildType({ + params { + param("env.KBN_NP_PLUGINS_BUILT", "true") + } + + dependencies { + ossBuild() + } + + init() + + addTestSettings() +}) diff --git a/.teamcity/src/builds/oss/OssPluginFunctional.kt b/.teamcity/src/builds/oss/OssPluginFunctional.kt new file mode 100644 index 00000000000000..7fbf863820e4c8 --- /dev/null +++ b/.teamcity/src/builds/oss/OssPluginFunctional.kt @@ -0,0 +1,29 @@ +package builds.oss + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script +import runbld + +object OssPluginFunctional : OssFunctionalBase({ + id("OssPluginFunctional") + name = "Plugin Functional" + + steps { + script { + name = "Build OSS Plugins" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/oss/build_plugins.sh + """.trimIndent() + } + + runbld("OSS Plugin Functional", "./.ci/teamcity/oss/plugin_functional.sh") + } + + dependencies { + ossBuild() + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/test/AllTests.kt b/.teamcity/src/builds/test/AllTests.kt new file mode 100644 index 00000000000000..d1b5898d1a5f5e --- /dev/null +++ b/.teamcity/src/builds/test/AllTests.kt @@ -0,0 +1,12 @@ +package builds.test + +import dependsOn +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType + +object AllTests : BuildType({ + name = "All Tests" + description = "All Non-Functional Tests" + type = Type.COMPOSITE + + dependsOn(QuickTests, Jest, XPackJest, JestIntegration, ApiServerIntegration) +}) diff --git a/.teamcity/src/builds/test/ApiServerIntegration.kt b/.teamcity/src/builds/test/ApiServerIntegration.kt new file mode 100644 index 00000000000000..d595840c879e67 --- /dev/null +++ b/.teamcity/src/builds/test/ApiServerIntegration.kt @@ -0,0 +1,17 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import runbld + +object ApiServerIntegration : BuildType({ + name = "API/Server Integration" + description = "Executes API and Server Integration Tests" + + steps { + runbld("API Integration", "yarn run grunt run:apiIntegrationTests") + runbld("Server Integration", "yarn run grunt run:serverIntegrationTests") + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/test/Jest.kt b/.teamcity/src/builds/test/Jest.kt new file mode 100644 index 00000000000000..04217a4e99b1c1 --- /dev/null +++ b/.teamcity/src/builds/test/Jest.kt @@ -0,0 +1,19 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import kibanaAgent +import runbld + +object Jest : BuildType({ + name = "Jest Unit" + description = "Executes Jest Unit Tests" + + kibanaAgent(8) + + steps { + runbld("Jest Unit", "yarn run grunt run:test_jest") + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/test/JestIntegration.kt b/.teamcity/src/builds/test/JestIntegration.kt new file mode 100644 index 00000000000000..9ec1360dcb1d76 --- /dev/null +++ b/.teamcity/src/builds/test/JestIntegration.kt @@ -0,0 +1,16 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import runbld + +object JestIntegration : BuildType({ + name = "Jest Integration" + description = "Executes Jest Integration Tests" + + steps { + runbld("Jest Integration", "yarn run grunt run:test_jest_integration") + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/test/QuickTests.kt b/.teamcity/src/builds/test/QuickTests.kt new file mode 100644 index 00000000000000..1fdb1e366e83fb --- /dev/null +++ b/.teamcity/src/builds/test/QuickTests.kt @@ -0,0 +1,29 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import kibanaAgent +import runbld + +object QuickTests : BuildType({ + name = "Quick Tests" + description = "Executes Quick Tests" + + kibanaAgent(2) + + val testScripts = mapOf( + "Test Hardening" to ".ci/teamcity/tests/test_hardening.sh", + "X-Pack List cyclic dependency" to ".ci/teamcity/tests/xpack_list_cyclic_dependency.sh", + "X-Pack SIEM cyclic dependency" to ".ci/teamcity/tests/xpack_siem_cyclic_dependency.sh", + "Test Projects" to ".ci/teamcity/tests/test_projects.sh", + "Mocha Tests" to ".ci/teamcity/tests/mocha.sh" + ) + + steps { + for (testScript in testScripts) { + runbld(testScript.key, testScript.value) + } + } + + addTestSettings() +}) diff --git a/.teamcity/src/builds/test/XPackJest.kt b/.teamcity/src/builds/test/XPackJest.kt new file mode 100644 index 00000000000000..1958d39183bae7 --- /dev/null +++ b/.teamcity/src/builds/test/XPackJest.kt @@ -0,0 +1,22 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import kibanaAgent +import runbld + +object XPackJest : BuildType({ + name = "X-Pack Jest Unit" + description = "Executes X-Pack Jest Unit Tests" + + kibanaAgent(16) + + steps { + runbld("X-Pack Jest Unit", """ + cd x-pack + node --max-old-space-size=6144 scripts/jest --ci --verbose --maxWorkers=6 + """.trimIndent()) + } + + addTestSettings() +}) diff --git a/.teamcity/src/projects/EsSnapshots.kt b/.teamcity/src/projects/EsSnapshots.kt new file mode 100644 index 00000000000000..a5aa47d5cae487 --- /dev/null +++ b/.teamcity/src/projects/EsSnapshots.kt @@ -0,0 +1,55 @@ +package projects + +import builds.es_snapshots.* +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import templates.KibanaTemplate + +object EsSnapshotsProject : Project({ + id("ES_Snapshots") + name = "ES Snapshots" + + subProject { + id("ES_Snapshot_Tests") + name = "Tests" + + defaultTemplate = KibanaTemplate + + subProject { + id("ES_Snapshot_Tests_OSS") + name = "OSS Distro Tests" + + ossCloned.forEach { + buildType(it) + } + + buildType(OssTests) + } + + subProject { + id("ES_Snapshot_Tests_Default") + name = "Default Distro Tests" + + defaultCloned.forEach { + buildType(it) + } + + buildType(DefaultTests) + } + + subProject { + id("ES_Snapshot_Tests_Integration") + name = "Integration Tests" + + integrationCloned.forEach { + buildType(it) + } + + buildType(IntegrationTests) + } + } + + buildType(ESSnapshotBuild) + buildType(ESSnapshotPromote) + buildType(ESSnapshotPromoteImmediate) + buildType(Verify) +}) diff --git a/.teamcity/src/projects/Kibana.kt b/.teamcity/src/projects/Kibana.kt new file mode 100644 index 00000000000000..20c30eedf5b91d --- /dev/null +++ b/.teamcity/src/projects/Kibana.kt @@ -0,0 +1,171 @@ +package projects + +import vcs.Kibana +import builds.* +import builds.default.* +import builds.oss.* +import builds.test.* +import jetbrains.buildServer.configs.kotlin.v2019_2.* +import jetbrains.buildServer.configs.kotlin.v2019_2.projectFeatures.slackConnection +import kibanaAgent +import templates.KibanaTemplate +import templates.DefaultTemplate +import vcs.Elasticsearch + +class KibanaConfiguration() { + var agentNetwork: String = "teamcity" + var agentSubnet: String = "teamcity" + + constructor(init: KibanaConfiguration.() -> Unit) : this() { + init() + } +} + +var kibanaConfiguration = KibanaConfiguration() + +fun Kibana(config: KibanaConfiguration = KibanaConfiguration()) : Project { + kibanaConfiguration = config + + return Project { + params { + param("teamcity.ui.settings.readOnly", "true") + + // https://github.com/JetBrains/teamcity-webhooks + param("teamcity.internal.webhooks.enable", "true") + param("teamcity.internal.webhooks.events", "BUILD_STARTED;BUILD_FINISHED;BUILD_INTERRUPTED;CHANGES_LOADED;BUILD_TYPE_ADDED_TO_QUEUE;BUILD_PROBLEMS_CHANGED") + param("teamcity.internal.webhooks.url", "https://ci-stats.kibana.dev/_teamcity_webhook") + param("teamcity.internal.webhooks.username", "automation") + password("teamcity.internal.webhooks.password", "credentialsJSON:b2ee34c5-fc89-4596-9b47-ecdeb68e4e7a", display = ParameterDisplay.HIDDEN) + } + + vcsRoot(Kibana) + vcsRoot(Elasticsearch) + + template(DefaultTemplate) + template(KibanaTemplate) + + defaultTemplate = DefaultTemplate + + features { + val sizes = listOf("2", "4", "8", "16") + for (size in sizes) { + kibanaAgent(size) + } + + kibanaAgent { + id = "KIBANA_C2_16" + param("source-id", "kibana-c2-16-") + param("machineType", "c2-standard-16") + } + + feature { + id = "kibana" + type = "CloudProfile" + param("agentPushPreset", "") + param("profileId", "kibana") + param("profileServerUrl", "") + param("name", "kibana") + param("total-work-time", "") + param("credentialsType", "key") + param("description", "") + param("next-hour", "") + param("cloud-code", "google") + param("terminate-after-build", "true") + param("terminate-idle-time", "30") + param("enabled", "true") + param("secure:accessKey", "credentialsJSON:447fdd4d-7129-46b7-9822-2e57658c7422") + } + + slackConnection { + id = "KIBANA_SLACK" + displayName = "Kibana Slack" + botToken = "credentialsJSON:39eafcfc-97a6-4877-82c1-115f1f10d14b" + clientId = "12985172978.1291178427153" + clientSecret = "credentialsJSON:8b5901fb-fd2c-4e45-8aff-fdd86dc68f67" + } + } + + subProject { + id("CI") + name = "CI" + defaultTemplate = KibanaTemplate + + buildType(Lint) + buildType(Checks) + + subProject { + id("Test") + name = "Test" + + subProject { + id("Jest") + name = "Jest" + + buildType(Jest) + buildType(XPackJest) + buildType(JestIntegration) + } + + buildType(ApiServerIntegration) + buildType(QuickTests) + buildType(AllTests) + } + + subProject { + id("OSS") + name = "OSS Distro" + + buildType(OssBuild) + + subProject { + id("OSS_Functional") + name = "Functional" + + buildType(OssCiGroups) + buildType(OssFirefox) + buildType(OssAccessibility) + buildType(OssPluginFunctional) + + subProject { + id("CIGroups") + name = "CI Groups" + + ossCiGroups.forEach { buildType(it) } + } + } + } + + subProject { + id("Default") + name = "Default Distro" + + buildType(DefaultBuild) + + subProject { + id("Default_Functional") + name = "Functional" + + buildType(DefaultCiGroups) + buildType(DefaultFirefox) + buildType(DefaultAccessibility) + buildType(DefaultSecuritySolution) + buildType(DefaultSavedObjectFieldMetrics) + + subProject { + id("Default_CIGroups") + name = "CI Groups" + + defaultCiGroups.forEach { buildType(it) } + } + } + } + + buildType(FullCi) + buildType(BaselineCi) + buildType(HourlyCi) + buildType(PullRequestCi) + } + + subProject(EsSnapshotsProject) + } +} diff --git a/.teamcity/src/templates/DefaultTemplate.kt b/.teamcity/src/templates/DefaultTemplate.kt new file mode 100644 index 00000000000000..762218b72ab107 --- /dev/null +++ b/.teamcity/src/templates/DefaultTemplate.kt @@ -0,0 +1,25 @@ +package templates + +import jetbrains.buildServer.configs.kotlin.v2019_2.Template +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.perfmon + +object DefaultTemplate : Template({ + name = "Default Template" + + requirements { + equals("system.cloud.profile_id", "kibana", "RQ_CLOUD_PROFILE_ID") + startsWith("teamcity.agent.name", "kibana-standard-2-", "RQ_AGENT_NAME") + } + + params { + param("env.HOME", "/var/lib/jenkins") // TODO once the agent images are sorted out + } + + features { + perfmon { } + } + + failureConditions { + executionTimeoutMin = 120 + } +}) diff --git a/.teamcity/src/templates/KibanaTemplate.kt b/.teamcity/src/templates/KibanaTemplate.kt new file mode 100644 index 00000000000000..117c30ddb86e31 --- /dev/null +++ b/.teamcity/src/templates/KibanaTemplate.kt @@ -0,0 +1,141 @@ +package templates + +import vcs.Kibana +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildStep +import jetbrains.buildServer.configs.kotlin.v2019_2.ParameterDisplay +import jetbrains.buildServer.configs.kotlin.v2019_2.Template +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.PullRequests +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.perfmon +import jetbrains.buildServer.configs.kotlin.v2019_2.buildFeatures.pullRequests +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.placeholder +import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script + +object KibanaTemplate : Template({ + name = "Kibana Template" + description = "For builds that need to check out kibana and execute against the repo using node" + + vcs { + root(Kibana) + + checkoutDir = "kibana" +// checkoutDir = "/dev/shm/%system.teamcity.buildType.id%/%system.build.number%/kibana" + } + + requirements { + equals("system.cloud.profile_id", "kibana", "RQ_CLOUD_PROFILE_ID") + startsWith("teamcity.agent.name", "kibana-standard-2-", "RQ_AGENT_NAME") + } + + features { + perfmon { } + pullRequests { + vcsRootExtId = "${Kibana.id}" + provider = github { + authType = token { + token = "credentialsJSON:07d22002-12de-4627-91c3-672bdb23b55b" + } + filterTargetBranch = "refs/heads/master_teamcity" + filterAuthorRole = PullRequests.GitHubRoleFilter.MEMBER + } + } + } + + failureConditions { + executionTimeoutMin = 120 + testFailure = false + } + + params { + param("env.CI", "true") + param("env.TEAMCITY_CI", "true") + param("env.HOME", "/var/lib/jenkins") // TODO once the agent images are sorted out + + // TODO remove these + param("env.GCS_UPLOAD_PREFIX", "INVALID_PREFIX") + param("env.CI_PARALLEL_PROCESS_NUMBER", "1") + + param("env.TEAMCITY_URL", "%teamcity.serverUrl%") + param("env.TEAMCITY_BUILD_URL", "%teamcity.serverUrl%/build/%teamcity.build.id%") + param("env.TEAMCITY_JOB_ID", "%system.teamcity.buildType.id%") + param("env.TEAMCITY_BUILD_ID", "%build.number%") + param("env.TEAMCITY_BUILD_NUMBER", "%teamcity.build.id%") + + param("env.GIT_BRANCH", "%vcsroot.branch%") + param("env.GIT_COMMIT", "%build.vcs.number%") + param("env.branch_specifier", "%vcsroot.branch%") + + password("env.KIBANA_CI_STATS_CONFIG", "", display = ParameterDisplay.HIDDEN) + password("env.CI_STATS_TOKEN", "credentialsJSON:ea975068-ca68-4da5-8189-ce90f4286bc0", display = ParameterDisplay.HIDDEN) + password("env.CI_STATS_HOST", "credentialsJSON:933ba93e-4b06-44c1-8724-8c536651f2b6", display = ParameterDisplay.HIDDEN) + + // TODO move these to vault once the configuration is finalized + // password("env.CI_STATS_TOKEN", "%vault:kibana-issues:secret/kibana-issues/dev/kibana_ci_stats!/api_token%", display = ParameterDisplay.HIDDEN) + // password("env.CI_STATS_HOST", "%vault:kibana-issues:secret/kibana-issues/dev/kibana_ci_stats!/api_host%", display = ParameterDisplay.HIDDEN) + + // TODO remove this once we are able to pull it out of vault and put it closer to the things that require it + password("env.GITHUB_TOKEN", "credentialsJSON:07d22002-12de-4627-91c3-672bdb23b55b", display = ParameterDisplay.HIDDEN) + password("env.KIBANA_CI_REPORTER_KEY", "", display = ParameterDisplay.HIDDEN) + password("env.KIBANA_CI_REPORTER_KEY_BASE64", "credentialsJSON:86878779-4cf7-4434-82af-5164a1b992fb", display = ParameterDisplay.HIDDEN) + + } + + steps { + script { + name = "Setup Environment" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/setup_env.sh + """.trimIndent() + } + + script { + name = "Setup Node and Yarn" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/setup_node.sh + """.trimIndent() + } + + script { + name = "Setup CI Stats" + scriptContent = + """ + #!/bin/bash + node .ci/teamcity/setup_ci_stats.js + """.trimIndent() + } + + script { + name = "Bootstrap" + scriptContent = + """ + #!/bin/bash + ./.ci/teamcity/bootstrap.sh + """.trimIndent() + } + + placeholder {} + + script { + name = "Set Build Status Success" + scriptContent = + """ + #!/bin/bash + echo "##teamcity[setParameter name='env.BUILD_STATUS' value='SUCCESS']" + """.trimIndent() + executionMode = BuildStep.ExecutionMode.RUN_ON_SUCCESS + } + + script { + name = "CI Stats Complete" + scriptContent = + """ + #!/bin/bash + node .ci/teamcity/ci_stats_complete.js + """.trimIndent() + executionMode = BuildStep.ExecutionMode.RUN_ON_FAILURE + } + } +}) diff --git a/.teamcity/src/vcs/Elasticsearch.kt b/.teamcity/src/vcs/Elasticsearch.kt new file mode 100644 index 00000000000000..ab7120b854446f --- /dev/null +++ b/.teamcity/src/vcs/Elasticsearch.kt @@ -0,0 +1,11 @@ +package vcs + +import jetbrains.buildServer.configs.kotlin.v2019_2.vcs.GitVcsRoot + +object Elasticsearch : GitVcsRoot({ + id("elasticsearch_master") + + name = "elasticsearch / master" + url = "https://github.com/elastic/elasticsearch.git" + branch = "refs/heads/master" +}) diff --git a/.teamcity/src/vcs/Kibana.kt b/.teamcity/src/vcs/Kibana.kt new file mode 100644 index 00000000000000..d847a1565e6e06 --- /dev/null +++ b/.teamcity/src/vcs/Kibana.kt @@ -0,0 +1,11 @@ +package vcs + +import jetbrains.buildServer.configs.kotlin.v2019_2.vcs.GitVcsRoot + +object Kibana : GitVcsRoot({ + id("kibana_master") + + name = "kibana / master" + url = "https://github.com/elastic/kibana.git" + branch = "refs/heads/master_teamcity" +}) diff --git a/.teamcity/tests/projects/KibanaTest.kt b/.teamcity/tests/projects/KibanaTest.kt new file mode 100644 index 00000000000000..677effec5be658 --- /dev/null +++ b/.teamcity/tests/projects/KibanaTest.kt @@ -0,0 +1,27 @@ +package projects + +import org.junit.Assert.* +import org.junit.Test + +val TestConfig = KibanaConfiguration { + agentNetwork = "network" + agentSubnet = "subnet" +} + +class KibanaTest { + @Test + fun test_Default_Configuration_Exists() { + assertNotNull(kibanaConfiguration) + Kibana() + assertEquals("teamcity", kibanaConfiguration.agentNetwork) + } + + @Test + fun test_CloudImages_Exist() { + val project = Kibana(TestConfig) + + assertTrue(project.features.items.any { + it.type == "CloudImage" && it.params.any { param -> param.name == "network" && param.value == "network"} + }) + } +} diff --git a/docs/discover/kuery.asciidoc b/docs/discover/kuery.asciidoc index 35f1160ee834d0..c1d287fca1f449 100644 --- a/docs/discover/kuery.asciidoc +++ b/docs/discover/kuery.asciidoc @@ -147,7 +147,7 @@ To match multiple fields: machine.os*:windows 10 ------------------- -This sytax is handy when you have text and keyword +This syntax is handy when you have text and keyword versions of a field. The query checks machine.os and machine.os.keyword for the term `windows 10`. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index dbac6997ff4333..6244a43b54f724 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -454,7 +454,8 @@ of buckets to try to represent. [horizontal] [[visualization-colormapping]]`visualization:colorMapping`:: -Maps values to specified colors in visualizations. +**This setting is deprecated and will not be supported as of 8.0.** +Maps values to specific colors in *Visualize* charts and *TSVB*. This setting does not apply to *Lens*. [[visualization-dimmingopacity]]`visualization:dimmingOpacity`:: The opacity of the chart items that are dimmed when highlighting another element diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index ef76121b21d290..649d4fe9512637 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -36,6 +36,12 @@ for example, `logstash-*`. === Settings changes // tag::notable-breaking-changes[] +[float] +==== Multitenancy by changing `kibana.index` is no longer supported +*Details:* `kibana.index`, `xpack.reporting.index` and `xpack.task_manager.index` can no longer be specified. + +*Impact:* Users who relied on changing these settings to achieve multitenancy should use *Spaces*, cross-cluster replication, or cross-cluster search instead. To migrate to *Spaces*, users are encouraged to use saved object management to export their saved objects from a tenant into the default tenant in a space. Improvements are planned to improve on this workflow. See https://github.com/elastic/kibana/issues/82020 for more details. + [float] ==== Legacy browsers are now rejected by default *Details:* `csp.strict` is now enabled by default, so Kibana will fail to load for older, legacy browsers that do not enforce basic Content Security Policy protections - notably Internet Explorer 11. diff --git a/docs/user/alerting/action-types.asciidoc b/docs/user/alerting/action-types.asciidoc index af80b17f8605f7..599cce3a03cd91 100644 --- a/docs/user/alerting/action-types.asciidoc +++ b/docs/user/alerting/action-types.asciidoc @@ -23,6 +23,9 @@ a| <> | Create an incident in Jira. +a| <> + +| Send a message to a Microsoft Teams channel. a| <> @@ -65,6 +68,7 @@ include::action-types/email.asciidoc[] include::action-types/resilient.asciidoc[] include::action-types/index.asciidoc[] include::action-types/jira.asciidoc[] +include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] diff --git a/docs/user/alerting/action-types/teams.asciidoc b/docs/user/alerting/action-types/teams.asciidoc new file mode 100644 index 00000000000000..6706dd2e5643fd --- /dev/null +++ b/docs/user/alerting/action-types/teams.asciidoc @@ -0,0 +1,58 @@ +[role="xpack"] +[[teams-action-type]] +=== Microsoft Teams action + +The Microsoft Teams action type uses https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook[Incoming Webhooks]. + +[float] +[[teams-connector-configuration]] +==== Connector configuration + +Microsoft Teams connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +Webhook URL:: The URL of the incoming webhook. See https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook#add-an-incoming-webhook-to-a-teams-channel[Add Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. + +[float] +[[Preconfigured-teams-configuration]] +==== Preconfigured action type + +[source,text] +-- + my-teams: + name: preconfigured-teams-action-type + actionTypeId: .teams + config: + webhookUrl: 'https://outlook.office.com/webhook/abcd@0123456/IncomingWebhook/abcdefgh/ijklmnopqrstuvwxyz' +-- + +`config` defines the action type specific to the configuration. +`config` contains +`webhookUrl`, a string that corresponds to *Webhook URL*. + + +[float] +[[teams-action-configuration]] +==== Action configuration + +Microsoft Teams actions have the following properties: + +Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. + +[[configuring-teams]] +==== Configuring Microsoft Teams Accounts + +You need a https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook[Microsoft Teams webhook URL] to +configure a Microsoft Teams action. To create a webhook +URL, add the **Incoming Webhook App** through the Microsoft Teams console: + +. Log in to http://teams.microsoft.com[teams.microsoft.com] as a team administrator. +. Navigate to the Apps directory, search for and select the *Incoming Webhook* app. +. Choose _Add to team_ and select a team and channel for the app. +. Enter a name for your webhook and (optionally) upload a custom icon. ++ +image::images/teams-add-webhook-integration.png[] +. Click *Create*. +. Copy the generated webhook URL so you can paste it into your Teams connector form. ++ +image::images/teams-copy-webhook-url.png[] diff --git a/docs/user/alerting/images/teams-add-webhook-integration.png b/docs/user/alerting/images/teams-add-webhook-integration.png new file mode 100644 index 00000000000000..a2d070cb337433 Binary files /dev/null and b/docs/user/alerting/images/teams-add-webhook-integration.png differ diff --git a/docs/user/alerting/images/teams-copy-webhook-url.png b/docs/user/alerting/images/teams-copy-webhook-url.png new file mode 100644 index 00000000000000..adb455c64cbf09 Binary files /dev/null and b/docs/user/alerting/images/teams-copy-webhook-url.png differ diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index ca788020d92862..1b9896d7dea565 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -3,7 +3,7 @@ == Create custom dashboard actions Custom dashboard actions, also known as drilldowns, allow you to create -workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns. +workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns. Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin]. @@ -28,7 +28,7 @@ Dashboard drilldowns enable you to open a dashboard from another dashboard, taking the time range, filters, and other parameters with you, so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective. -For example, if you have a dashboard that shows the overall status of multiple data center, +For example, if you have a dashboard that shows the overall status of multiple data center, you can create a drilldown that navigates from the overall status dashboard to a dashboard that shows a single data center or server. @@ -41,14 +41,14 @@ Destination URLs can be dynamic, depending on the dashboard context or user inte For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown that opens Github from the dashboard. -Some panels support multiple interactions, also known as triggers. +Some panels support multiple interactions, also known as triggers. The <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: * *Single click* — A single data point in the visualization. * *Range selection* — A range of values in a visualization. -For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. +For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. To disable URL drilldowns on your {kib} instance, disable the plugin: @@ -77,20 +77,20 @@ The following panels support dashboard and URL drilldowns. ^| X | Controls -^| -^| +^| +^| | Data Table ^| X ^| X | Gauge -^| -^| +^| +^| | Goal -^| -^| +^| +^| | Heat map ^| X @@ -106,15 +106,15 @@ The following panels support dashboard and URL drilldowns. | Maps ^| X -^| +^| X | Markdown -^| -^| +^| +^| | Metric -^| -^| +^| +^| | Pie ^| X @@ -122,7 +122,7 @@ The following panels support dashboard and URL drilldowns. | TSVB ^| X -^| +^| | Tag Cloud ^| X @@ -130,11 +130,11 @@ The following panels support dashboard and URL drilldowns. | Timelion ^| X -^| +^| | Vega ^| X -^| +^| | Vertical Bar ^| X @@ -192,7 +192,7 @@ image::images/drilldown_create.png[Create drilldown with entries for drilldown n . Click *Create drilldown*. + -The drilldown is stored as dashboard metadata. +The drilldown is stored as dashboard metadata. . Save the dashboard. + @@ -226,7 +226,7 @@ image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigate .. Select *Go to URL*. -.. Enter the URL template: +.. Enter the URL template: + [source, bash] ---- @@ -240,7 +240,7 @@ image:images/url_drilldown_url_template.png[URL template input] .. Click *Create drilldown*. + -The drilldown is stored as dashboard metadata. +The drilldown is stored as dashboard metadata. . Save the dashboard. + diff --git a/package.json b/package.json index 33a509e8637934..0265250842756d 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", + "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/std": "link:packages/kbn-std", @@ -419,7 +420,6 @@ "@types/cmd-shim": "^2.0.0", "@types/color": "^3.0.0", "@types/compression-webpack-plugin": "^2.0.2", - "@types/console-stamp": "^0.2.32", "@types/cypress-cucumber-preprocessor": "^1.14.1", "@types/cytoscape": "^3.14.0", "@types/d3": "^3.5.43", @@ -602,7 +602,6 @@ "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compare-versions": "3.5.1", - "console-stamp": "^0.2.9", "constate": "^1.3.2", "copy-to-clipboard": "^3.0.8", "copy-webpack-plugin": "^6.0.2", @@ -723,7 +722,7 @@ "less": "npm:@elastic/less@2.7.3-kibana", "license-checker": "^16.0.0", "listr": "^0.14.1", - "lmdb-store": "^0.6.10", + "lmdb-store": "^0.8.15", "load-grunt-config": "^3.0.1", "loader-utils": "^1.2.3", "log-symbols": "^2.2.0", diff --git a/packages/kbn-legacy-logging/README.md b/packages/kbn-legacy-logging/README.md new file mode 100644 index 00000000000000..4c5989fc892dc5 --- /dev/null +++ b/packages/kbn-legacy-logging/README.md @@ -0,0 +1,4 @@ +# @kbn/legacy-logging + +This package contains the implementation of the legacy logging +system, based on `@hapi/good` \ No newline at end of file diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json new file mode 100644 index 00000000000000..9311b3e2a77b37 --- /dev/null +++ b/packages/kbn-legacy-logging/package.json @@ -0,0 +1,15 @@ +{ + "name": "@kbn/legacy-logging", + "version": "1.0.0", + "private": true, + "license": "Apache-2.0", + "main": "./target/index.js", + "scripts": { + "build": "tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "dependencies": { + "@kbn/std": "link:../kbn-std" + } +} diff --git a/src/legacy/server/logging/configuration.js b/packages/kbn-legacy-logging/src/get_logging_config.ts similarity index 74% rename from src/legacy/server/logging/configuration.js rename to packages/kbn-legacy-logging/src/get_logging_config.ts index 267dc9a334de88..cf49177e50b7be 100644 --- a/src/legacy/server/logging/configuration.js +++ b/packages/kbn-legacy-logging/src/get_logging_config.ts @@ -18,20 +18,25 @@ */ import _ from 'lodash'; -import { getLoggerStream } from './log_reporter'; +import { getLogReporter } from './log_reporter'; +import { LegacyLoggingConfig } from './schema'; -export default function loggingConfiguration(config) { - const events = config.get('logging.events'); +/** + * Returns the `@hapi/good` plugin configuration to be used for the legacy logging + * @param config + */ +export function getLoggingConfiguration(config: LegacyLoggingConfig, opsInterval: number) { + const events = config.events; - if (config.get('logging.silent')) { + if (config.silent) { _.defaults(events, {}); - } else if (config.get('logging.quiet')) { + } else if (config.quiet) { _.defaults(events, { log: ['listening', 'error', 'fatal'], request: ['error'], error: '*', }); - } else if (config.get('logging.verbose')) { + } else if (config.verbose) { _.defaults(events, { log: '*', ops: '*', @@ -47,24 +52,24 @@ export default function loggingConfiguration(config) { }); } - const loggerStream = getLoggerStream({ + const loggerStream = getLogReporter({ config: { - json: config.get('logging.json'), - dest: config.get('logging.dest'), - timezone: config.get('logging.timezone'), + json: config.json, + dest: config.dest, + timezone: config.timezone, // I'm adding the default here because if you add another filter // using the commandline it will remove authorization. I want users // to have to explicitly set --logging.filter.authorization=none or // --logging.filter.cookie=none to have it show up in the logs. - filter: _.defaults(config.get('logging.filter'), { + filter: _.defaults(config.filter, { authorization: 'remove', cookie: 'remove', }), }, events: _.transform( events, - function (filtered, val, key) { + function (filtered: Record, val: string, key: string) { // provide a string compatible way to remove events if (val !== '!') filtered[key] = val; }, @@ -74,7 +79,7 @@ export default function loggingConfiguration(config) { const options = { ops: { - interval: config.get('ops.interval'), + interval: opsInterval, }, includes: { request: ['headers', 'payload'], diff --git a/packages/kbn-legacy-logging/src/index.ts b/packages/kbn-legacy-logging/src/index.ts new file mode 100644 index 00000000000000..0fa5f65abf8617 --- /dev/null +++ b/packages/kbn-legacy-logging/src/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { LegacyLoggingConfig, legacyLoggingConfigSchema } from './schema'; +export { attachMetaData } from './metadata'; +export { setupLoggingRotate } from './rotate'; +export { setupLogging, reconfigureLogging } from './setup_logging'; +export { getLoggingConfiguration } from './get_logging_config'; +export { LegacyLoggingServer } from './legacy_logging_server'; diff --git a/src/core/server/legacy/logging/legacy_logging_server.test.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.test.ts similarity index 86% rename from src/core/server/legacy/logging/legacy_logging_server.test.ts rename to packages/kbn-legacy-logging/src/legacy_logging_server.test.ts index 2f6c34e0fc5d6b..9b1ba87c250dcc 100644 --- a/src/core/server/legacy/logging/legacy_logging_server.test.ts +++ b/packages/kbn-legacy-logging/src/legacy_logging_server.test.ts @@ -17,11 +17,9 @@ * under the License. */ -jest.mock('../../../../legacy/server/config'); -jest.mock('../../../../legacy/server/logging'); +jest.mock('./setup_logging'); -import { LogLevel } from '../../logging'; -import { LegacyLoggingServer } from './legacy_logging_server'; +import { LegacyLoggingServer, LogRecord } from './legacy_logging_server'; test('correctly forwards log records.', () => { const loggingServer = new LegacyLoggingServer({ events: {} }); @@ -29,28 +27,37 @@ test('correctly forwards log records.', () => { loggingServer.events.on('log', onLogMock); const timestamp = 1554433221100; - const firstLogRecord = { + const firstLogRecord: LogRecord = { timestamp: new Date(timestamp), pid: 5355, - level: LogLevel.Info, + level: { + id: 'info', + value: 5, + }, context: 'some-context', message: 'some-message', }; - const secondLogRecord = { + const secondLogRecord: LogRecord = { timestamp: new Date(timestamp), pid: 5355, - level: LogLevel.Error, + level: { + id: 'error', + value: 3, + }, context: 'some-context.sub-context', message: 'some-message', meta: { unknown: 2 }, error: new Error('some-error'), }; - const thirdLogRecord = { + const thirdLogRecord: LogRecord = { timestamp: new Date(timestamp), pid: 5355, - level: LogLevel.Trace, + level: { + id: 'trace', + value: 7, + }, context: 'some-context.sub-context', message: 'some-message', meta: { tags: ['important', 'tags'], unknown: 2 }, diff --git a/src/core/server/legacy/logging/legacy_logging_server.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.ts similarity index 73% rename from src/core/server/legacy/logging/legacy_logging_server.ts rename to packages/kbn-legacy-logging/src/legacy_logging_server.ts index 690c9c0bfe21d5..45e4bda0b007c0 100644 --- a/src/core/server/legacy/logging/legacy_logging_server.ts +++ b/packages/kbn-legacy-logging/src/legacy_logging_server.ts @@ -17,29 +17,40 @@ * under the License. */ -import { ServerExtType } from '@hapi/hapi'; -import Podium from '@hapi/podium'; -// @ts-expect-error: implicit any for JS file -import { Config } from '../../../../legacy/server/config'; -// @ts-expect-error: implicit any for JS file -import { setupLogging } from '../../../../legacy/server/logging'; -import { LogLevel, LogRecord } from '../../logging'; -import { LegacyVars } from '../../types'; - -export const metadataSymbol = Symbol('log message with metadata'); -export function attachMetaData(message: string, metadata: LegacyVars = {}) { - return { - [metadataSymbol]: { - message, - metadata, - }, - }; +import { ServerExtType, Server } from '@hapi/hapi'; +import Podium from 'podium'; +import { setupLogging } from './setup_logging'; +import { attachMetaData } from './metadata'; +import { legacyLoggingConfigSchema } from './schema'; + +// these LogXXX types are duplicated to avoid a cross dependency with the @kbn/logging package. +// typescript will error if they diverge at some point. +type LogLevelId = 'all' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'off'; + +interface LogLevel { + id: LogLevelId; + value: number; +} + +export interface LogRecord { + timestamp: Date; + level: LogLevel; + context: string; + message: string; + error?: Error; + meta?: { [name: string]: any }; + pid: number; } + const isEmptyObject = (obj: object) => Object.keys(obj).length === 0; function getDataToLog(error: Error | undefined, metadata: object, message: string) { - if (error) return error; - if (!isEmptyObject(metadata)) return attachMetaData(message, metadata); + if (error) { + return error; + } + if (!isEmptyObject(metadata)) { + return attachMetaData(message, metadata); + } return message; } @@ -50,7 +61,7 @@ interface PluginRegisterParams { options: PluginRegisterParams['options'] ) => Promise; }; - options: LegacyVars; + options: Record; } /** @@ -84,22 +95,19 @@ export class LegacyLoggingServer { private onPostStopCallback?: () => void; - constructor(legacyLoggingConfig: Readonly) { + constructor(legacyLoggingConfig: any) { // We set `ops.interval` to max allowed number and `ops` filter to value // that doesn't exist to avoid logging of ops at all, if turned on it will be // logged by the "legacy" Kibana. - const config = { - logging: { - ...legacyLoggingConfig, - events: { - ...legacyLoggingConfig.events, - ops: '__no-ops__', - }, + const { value: loggingConfig } = legacyLoggingConfigSchema.validate({ + ...legacyLoggingConfig, + events: { + ...legacyLoggingConfig.events, + ops: '__no-ops__', }, - ops: { interval: 2147483647 }, - }; + }); - setupLogging(this, Config.withDefaultSchema(config)); + setupLogging((this as unknown) as Server, loggingConfig, 2147483647); } public register({ plugin: { register }, options }: PluginRegisterParams): Promise { diff --git a/packages/kbn-legacy-logging/src/log_events.ts b/packages/kbn-legacy-logging/src/log_events.ts new file mode 100644 index 00000000000000..296c255a75185c --- /dev/null +++ b/packages/kbn-legacy-logging/src/log_events.ts @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventData, isEventData } from './metadata'; + +export interface BaseEvent { + event: string; + timestamp: number; + pid: number; + tags?: string[]; +} + +export interface ResponseEvent extends BaseEvent { + event: 'response'; + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + statusCode: number; + path: string; + headers: Record; + responsePayload: string; + responseTime: string; + query: Record; +} + +export interface OpsEvent extends BaseEvent { + event: 'ops'; + os: { + load: string[]; + }; + proc: Record; + load: string; +} + +export interface ErrorEvent extends BaseEvent { + event: 'error'; + error: Error; + url: string; +} + +export interface UndeclaredErrorEvent extends BaseEvent { + error: Error; +} + +export interface LogEvent extends BaseEvent { + data: EventData; +} + +export interface UnkownEvent extends BaseEvent { + data: string | Record; +} + +export type AnyEvent = + | ResponseEvent + | OpsEvent + | ErrorEvent + | UndeclaredErrorEvent + | LogEvent + | UnkownEvent; + +export const isResponseEvent = (e: AnyEvent): e is ResponseEvent => e.event === 'response'; +export const isOpsEvent = (e: AnyEvent): e is OpsEvent => e.event === 'ops'; +export const isErrorEvent = (e: AnyEvent): e is ErrorEvent => e.event === 'error'; +export const isLogEvent = (e: AnyEvent): e is LogEvent => isEventData((e as LogEvent).data); +export const isUndeclaredErrorEvent = (e: AnyEvent): e is UndeclaredErrorEvent => + (e as any).error instanceof Error; diff --git a/src/legacy/server/logging/log_format.js b/packages/kbn-legacy-logging/src/log_format.ts similarity index 61% rename from src/legacy/server/logging/log_format.js rename to packages/kbn-legacy-logging/src/log_format.ts index 6edda8c4be9076..e357c2420c1788 100644 --- a/src/legacy/server/logging/log_format.js +++ b/packages/kbn-legacy-logging/src/log_format.ts @@ -19,16 +19,29 @@ import Stream from 'stream'; import moment from 'moment-timezone'; -import { get, _ } from 'lodash'; +import _ from 'lodash'; import queryString from 'query-string'; import numeral from '@elastic/numeral'; import chalk from 'chalk'; +// @ts-expect-error missing type def import stringify from 'json-stringify-safe'; -import applyFiltersToKeys from './apply_filters_to_keys'; import { inspect } from 'util'; -import { logWithMetadata } from './log_with_metadata'; -function serializeError(err = {}) { +import { applyFiltersToKeys } from './utils'; +import { getLogEventData } from './metadata'; +import { LegacyLoggingConfig } from './schema'; +import { + AnyEvent, + isResponseEvent, + isOpsEvent, + isErrorEvent, + isLogEvent, + isUndeclaredErrorEvent, +} from './log_events'; + +export type LogFormatConfig = Pick; + +function serializeError(err: any = {}) { return { message: err.message, name: err.name, @@ -38,34 +51,37 @@ function serializeError(err = {}) { }; } -const levelColor = function (code) { - if (code < 299) return chalk.green(code); - if (code < 399) return chalk.yellow(code); - if (code < 499) return chalk.magentaBright(code); - return chalk.red(code); +const levelColor = function (code: number) { + if (code < 299) return chalk.green(String(code)); + if (code < 399) return chalk.yellow(String(code)); + if (code < 499) return chalk.magentaBright(String(code)); + return chalk.red(String(code)); }; -export default class TransformObjStream extends Stream.Transform { - constructor(config) { +export abstract class BaseLogFormat extends Stream.Transform { + constructor(private readonly config: LogFormatConfig) { super({ readableObjectMode: false, writableObjectMode: true, }); - this.config = config; } - filter(data) { - if (!this.config.filter) return data; + abstract format(data: Record): string; + + filter(data: Record) { + if (!this.config.filter) { + return data; + } return applyFiltersToKeys(data, this.config.filter); } - _transform(event, enc, next) { + _transform(event: AnyEvent, enc: string, next: Stream.TransformCallback) { const data = this.filter(this.readEvent(event)); this.push(this.format(data) + '\n'); next(); } - extractAndFormatTimestamp(data, format) { + extractAndFormatTimestamp(data: Record, format?: string) { const { timezone } = this.config; const date = moment(data['@timestamp']); if (timezone) { @@ -74,18 +90,18 @@ export default class TransformObjStream extends Stream.Transform { return date.format(format); } - readEvent(event) { - const data = { + readEvent(event: AnyEvent) { + const data: Record = { type: event.event, '@timestamp': event.timestamp, - tags: [].concat(event.tags || []), + tags: [...(event.tags || [])], pid: event.pid, }; - if (data.type === 'response') { + if (isResponseEvent(event)) { _.defaults(data, _.pick(event, ['method', 'statusCode'])); - const source = get(event, 'source', {}); + const source = _.get(event, 'source', {}); data.req = { url: event.path, method: event.method || '', @@ -95,21 +111,21 @@ export default class TransformObjStream extends Stream.Transform { referer: source.referer, }; - let contentLength = 0; - if (typeof event.responsePayload === 'object') { - contentLength = stringify(event.responsePayload).length; - } else { - contentLength = String(event.responsePayload).length; - } + const contentLength = + event.responsePayload === 'object' + ? stringify(event.responsePayload).length + : String(event.responsePayload).length; data.res = { statusCode: event.statusCode, responseTime: event.responseTime, - contentLength: contentLength, + contentLength, }; const query = queryString.stringify(event.query, { sort: false }); - if (query) data.req.url += '?' + query; + if (query) { + data.req.url += '?' + query; + } data.message = data.req.method.toUpperCase() + ' '; data.message += data.req.url; @@ -118,38 +134,38 @@ export default class TransformObjStream extends Stream.Transform { data.message += ' '; data.message += chalk.gray(data.res.responseTime + 'ms'); data.message += chalk.gray(' - ' + numeral(contentLength).format('0.0b')); - } else if (data.type === 'ops') { + } else if (isOpsEvent(event)) { _.defaults(data, _.pick(event, ['pid', 'os', 'proc', 'load'])); data.message = chalk.gray('memory: '); - data.message += numeral(get(data, 'proc.mem.heapUsed')).format('0.0b'); + data.message += numeral(_.get(data, 'proc.mem.heapUsed')).format('0.0b'); data.message += ' '; data.message += chalk.gray('uptime: '); - data.message += numeral(get(data, 'proc.uptime')).format('00:00:00'); + data.message += numeral(_.get(data, 'proc.uptime')).format('00:00:00'); data.message += ' '; data.message += chalk.gray('load: ['); - data.message += get(data, 'os.load', []) - .map(function (val) { + data.message += _.get(data, 'os.load', []) + .map((val: number) => { return numeral(val).format('0.00'); }) .join(' '); data.message += chalk.gray(']'); data.message += ' '; data.message += chalk.gray('delay: '); - data.message += numeral(get(data, 'proc.delay')).format('0.000'); - } else if (data.type === 'error') { + data.message += numeral(_.get(data, 'proc.delay')).format('0.000'); + } else if (isErrorEvent(event)) { data.level = 'error'; data.error = serializeError(event.error); data.url = event.url; - const message = get(event, 'error.message'); + const message = _.get(event, 'error.message'); data.message = message || 'Unknown error (no message)'; - } else if (event.error instanceof Error) { + } else if (isUndeclaredErrorEvent(event)) { data.type = 'error'; data.level = _.includes(event.tags, 'fatal') ? 'fatal' : 'error'; data.error = serializeError(event.error); - const message = get(event, 'error.message'); + const message = _.get(event, 'error.message'); data.message = message || 'Unknown error object (no message)'; - } else if (logWithMetadata.isLogEvent(event.data)) { - _.assign(data, logWithMetadata.getLogEventData(event.data)); + } else if (isLogEvent(event)) { + _.assign(data, getLogEventData(event.data)); } else { data.message = _.isString(event.data) ? event.data : inspect(event.data); } diff --git a/src/legacy/server/logging/log_format_json.test.js b/packages/kbn-legacy-logging/src/log_format_json.test.ts similarity index 79% rename from src/legacy/server/logging/log_format_json.test.js rename to packages/kbn-legacy-logging/src/log_format_json.test.ts index ec7296d21672b2..f762daf01e5fa9 100644 --- a/src/legacy/server/logging/log_format_json.test.js +++ b/packages/kbn-legacy-logging/src/log_format_json.test.ts @@ -19,30 +19,31 @@ import moment from 'moment'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { attachMetaData } from '../../../../src/core/server/legacy/logging/legacy_logging_server'; -import { createListStream, createPromiseFromStreams } from '../../../core/server/utils'; - -import KbnLoggerJsonFormat from './log_format_json'; +import { attachMetaData } from './metadata'; +import { createListStream, createPromiseFromStreams } from './test_utils'; +import { KbnLoggerJsonFormat } from './log_format_json'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); -const makeEvent = (eventType) => ({ +const makeEvent = (eventType: string) => ({ event: eventType, timestamp: time, }); describe('KbnLoggerJsonFormat', () => { - const config = {}; + const config: any = {}; describe('event types and messages', () => { - let format; + let format: KbnLoggerJsonFormat; beforeEach(() => { format = new KbnLoggerJsonFormat(config); }); it('log', async () => { - const result = await createPromiseFromStreams([createListStream([makeEvent('log')]), format]); + const result = await createPromiseFromStreams([ + createListStream([makeEvent('log')]), + format, + ]); const { type, message } = JSON.parse(result); expect(type).toBe('log'); @@ -64,7 +65,7 @@ describe('KbnLoggerJsonFormat', () => { referer: 'elastic.co', }, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { type, method, statusCode, message, req } = JSON.parse(result); expect(type).toBe('response'); @@ -82,7 +83,7 @@ describe('KbnLoggerJsonFormat', () => { load: [1, 1, 2], }, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { type, message } = JSON.parse(result); expect(type).toBe('ops'); @@ -98,7 +99,7 @@ describe('KbnLoggerJsonFormat', () => { }), tags: ['tag1', 'tag2'], }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, prop1, prop2, tags } = JSON.parse(result); expect(level).toBe(undefined); @@ -117,7 +118,7 @@ describe('KbnLoggerJsonFormat', () => { }), tags: ['tag1', 'tag2'], }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, prop1, prop2, tags } = JSON.parse(result); expect(level).toBe(undefined); @@ -132,7 +133,7 @@ describe('KbnLoggerJsonFormat', () => { data: attachMetaData('message for event'), tags: ['tag1', 'tag2'], }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, prop1, prop2, tags } = JSON.parse(result); expect(level).toBe(undefined); @@ -151,7 +152,7 @@ describe('KbnLoggerJsonFormat', () => { }), tags: ['tag1', 'tag2'], }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, prop1, prop2, tags } = JSON.parse(result); expect(level).toBe('error'); @@ -170,7 +171,7 @@ describe('KbnLoggerJsonFormat', () => { message: 'test error 0', }, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, error } = JSON.parse(result); expect(level).toBe('error'); @@ -183,7 +184,7 @@ describe('KbnLoggerJsonFormat', () => { event: 'error', error: {}, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, error } = JSON.parse(result); expect(level).toBe('error'); @@ -193,9 +194,9 @@ describe('KbnLoggerJsonFormat', () => { it('event error instanceof Error', async () => { const event = { - error: new Error('test error 2'), + error: new Error('test error 2') as any, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, error } = JSON.parse(result); expect(level).toBe('error'); @@ -210,10 +211,10 @@ describe('KbnLoggerJsonFormat', () => { it('event error instanceof Error - fatal', async () => { const event = { - error: new Error('test error 2'), + error: new Error('test error 2') as any, tags: ['fatal', 'tag2'], }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { tags, level, message, error } = JSON.parse(result); expect(tags).toEqual(['fatal', 'tag2']); @@ -229,9 +230,9 @@ describe('KbnLoggerJsonFormat', () => { it('event error instanceof Error, no message', async () => { const event = { - error: new Error(''), + error: new Error('') as any, }; - const result = await createPromiseFromStreams([createListStream([event]), format]); + const result = await createPromiseFromStreams([createListStream([event]), format]); const { level, message, error } = JSON.parse(result); expect(level).toBe('error'); @@ -250,18 +251,24 @@ describe('KbnLoggerJsonFormat', () => { it('logs in UTC', async () => { const format = new KbnLoggerJsonFormat({ timezone: 'UTC', - }); + } as any); - const result = await createPromiseFromStreams([createListStream([makeEvent('log')]), format]); + const result = await createPromiseFromStreams([ + createListStream([makeEvent('log')]), + format, + ]); const { '@timestamp': timestamp } = JSON.parse(result); expect(timestamp).toBe(moment.utc(time).format()); }); it('logs in local timezone timezone is undefined', async () => { - const format = new KbnLoggerJsonFormat({}); + const format = new KbnLoggerJsonFormat({} as any); - const result = await createPromiseFromStreams([createListStream([makeEvent('log')]), format]); + const result = await createPromiseFromStreams([ + createListStream([makeEvent('log')]), + format, + ]); const { '@timestamp': timestamp } = JSON.parse(result); expect(timestamp).toBe(moment(time).format()); diff --git a/src/legacy/server/logging/log_format_json.js b/packages/kbn-legacy-logging/src/log_format_json.ts similarity index 82% rename from src/legacy/server/logging/log_format_json.js rename to packages/kbn-legacy-logging/src/log_format_json.ts index bfceb78b24504c..7961fda7912ccc 100644 --- a/src/legacy/server/logging/log_format_json.js +++ b/packages/kbn-legacy-logging/src/log_format_json.ts @@ -17,15 +17,16 @@ * under the License. */ -import LogFormat from './log_format'; +// @ts-expect-error missing type def import stringify from 'json-stringify-safe'; +import { BaseLogFormat } from './log_format'; -const stripColors = function (string) { +const stripColors = function (string: string) { return string.replace(/\u001b[^m]+m/g, ''); }; -export default class KbnLoggerJsonFormat extends LogFormat { - format(data) { +export class KbnLoggerJsonFormat extends BaseLogFormat { + format(data: Record) { data.message = stripColors(data.message); data['@timestamp'] = this.extractAndFormatTimestamp(data); return stringify(data); diff --git a/src/legacy/server/logging/log_format_string.test.js b/packages/kbn-legacy-logging/src/log_format_string.test.ts similarity index 84% rename from src/legacy/server/logging/log_format_string.test.js rename to packages/kbn-legacy-logging/src/log_format_string.test.ts index 842325865cce22..0ed233228c1fd9 100644 --- a/src/legacy/server/logging/log_format_string.test.js +++ b/packages/kbn-legacy-logging/src/log_format_string.test.ts @@ -18,12 +18,10 @@ */ import moment from 'moment'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { attachMetaData } from '../../../../src/core/server/legacy/logging/legacy_logging_server'; -import { createListStream, createPromiseFromStreams } from '../../../core/server/utils'; - -import KbnLoggerStringFormat from './log_format_string'; +import { attachMetaData } from './metadata'; +import { createListStream, createPromiseFromStreams } from './test_utils'; +import { KbnLoggerStringFormat } from './log_format_string'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); @@ -39,7 +37,7 @@ describe('KbnLoggerStringFormat', () => { it('logs in UTC', async () => { const format = new KbnLoggerStringFormat({ timezone: 'UTC', - }); + } as any); const result = await createPromiseFromStreams([createListStream([makeEvent()]), format]); @@ -47,7 +45,7 @@ describe('KbnLoggerStringFormat', () => { }); it('logs in local timezone when timezone is undefined', async () => { - const format = new KbnLoggerStringFormat({}); + const format = new KbnLoggerStringFormat({} as any); const result = await createPromiseFromStreams([createListStream([makeEvent()]), format]); @@ -55,7 +53,7 @@ describe('KbnLoggerStringFormat', () => { }); describe('with metadata', () => { it('does not log meta data', async () => { - const format = new KbnLoggerStringFormat({}); + const format = new KbnLoggerStringFormat({} as any); const event = { data: attachMetaData('message for event', { prop1: 'value1', diff --git a/src/legacy/server/logging/log_format_string.js b/packages/kbn-legacy-logging/src/log_format_string.ts similarity index 84% rename from src/legacy/server/logging/log_format_string.js rename to packages/kbn-legacy-logging/src/log_format_string.ts index cbbf71dd894acb..3f024fac551199 100644 --- a/src/legacy/server/logging/log_format_string.js +++ b/packages/kbn-legacy-logging/src/log_format_string.ts @@ -20,11 +20,11 @@ import _ from 'lodash'; import chalk from 'chalk'; -import LogFormat from './log_format'; +import { BaseLogFormat } from './log_format'; const statuses = ['err', 'info', 'error', 'warning', 'fatal', 'status', 'debug']; -const typeColors = { +const typeColors: Record = { log: 'white', req: 'green', res: 'green', @@ -45,18 +45,19 @@ const typeColors = { scss: 'magentaBright', }; -const color = _.memoize(function (name) { +const color = _.memoize((name: string): ((...text: string[]) => string) => { + // @ts-expect-error couldn't even get rid of the error with an any cast return chalk[typeColors[name]] || _.identity; }); -const type = _.memoize(function (t) { +const type = _.memoize((t: string) => { return color(t)(_.pad(t, 7).slice(0, 7)); }); const workerType = process.env.kbnWorkerType ? `${type(process.env.kbnWorkerType)} ` : ''; -export default class KbnLoggerStringFormat extends LogFormat { - format(data) { +export class KbnLoggerStringFormat extends BaseLogFormat { + format(data: Record) { const time = color('time')(this.extractAndFormatTimestamp(data, 'HH:mm:ss.SSS')); const msg = data.error ? color('error')(data.error.stack) : color('message')(data.message); diff --git a/src/legacy/server/logging/log_interceptor.test.js b/packages/kbn-legacy-logging/src/log_interceptor.test.ts similarity index 90% rename from src/legacy/server/logging/log_interceptor.test.js rename to packages/kbn-legacy-logging/src/log_interceptor.test.ts index 492d1ffc8d167f..32da6432cc443a 100644 --- a/src/legacy/server/logging/log_interceptor.test.js +++ b/packages/kbn-legacy-logging/src/log_interceptor.test.ts @@ -17,13 +17,15 @@ * under the License. */ +import { ErrorEvent } from './log_events'; import { LogInterceptor } from './log_interceptor'; -function stubClientErrorEvent(errorMeta) { +function stubClientErrorEvent(errorMeta: Record): ErrorEvent { const error = new Error(); Object.assign(error, errorMeta); return { event: 'error', + url: '', pid: 1234, timestamp: Date.now(), tags: ['connection', 'client', 'error'], @@ -35,7 +37,7 @@ const stubEconnresetEvent = () => stubClientErrorEvent({ code: 'ECONNRESET' }); const stubEpipeEvent = () => stubClientErrorEvent({ errno: 'EPIPE' }); const stubEcanceledEvent = () => stubClientErrorEvent({ errno: 'ECANCELED' }); -function assertDowngraded(transformed) { +function assertDowngraded(transformed: Record) { expect(!!transformed).toBe(true); expect(transformed).toHaveProperty('event', 'log'); expect(transformed).toHaveProperty('tags'); @@ -47,13 +49,13 @@ describe('server logging LogInterceptor', () => { it('transforms ECONNRESET events', () => { const interceptor = new LogInterceptor(); const event = stubEconnresetEvent(); - assertDowngraded(interceptor.downgradeIfEconnreset(event)); + assertDowngraded(interceptor.downgradeIfEconnreset(event)!); }); it('does not match if the tags are not in order', () => { const interceptor = new LogInterceptor(); const event = stubEconnresetEvent(); - event.tags = [...event.tags.slice(1), event.tags[0]]; + event.tags = [...event.tags!.slice(1), event.tags![0]]; expect(interceptor.downgradeIfEconnreset(event)).toBe(null); }); @@ -75,13 +77,13 @@ describe('server logging LogInterceptor', () => { it('transforms EPIPE events', () => { const interceptor = new LogInterceptor(); const event = stubEpipeEvent(); - assertDowngraded(interceptor.downgradeIfEpipe(event)); + assertDowngraded(interceptor.downgradeIfEpipe(event)!); }); it('does not match if the tags are not in order', () => { const interceptor = new LogInterceptor(); const event = stubEpipeEvent(); - event.tags = [...event.tags.slice(1), event.tags[0]]; + event.tags = [...event.tags!.slice(1), event.tags![0]]; expect(interceptor.downgradeIfEpipe(event)).toBe(null); }); @@ -103,13 +105,13 @@ describe('server logging LogInterceptor', () => { it('transforms ECANCELED events', () => { const interceptor = new LogInterceptor(); const event = stubEcanceledEvent(); - assertDowngraded(interceptor.downgradeIfEcanceled(event)); + assertDowngraded(interceptor.downgradeIfEcanceled(event)!); }); it('does not match if the tags are not in order', () => { const interceptor = new LogInterceptor(); const event = stubEcanceledEvent(); - event.tags = [...event.tags.slice(1), event.tags[0]]; + event.tags = [...event.tags!.slice(1), event.tags![0]]; expect(interceptor.downgradeIfEcanceled(event)).toBe(null); }); @@ -131,7 +133,7 @@ describe('server logging LogInterceptor', () => { it('transforms https requests when serving http errors', () => { const interceptor = new LogInterceptor(); const event = stubClientErrorEvent({ message: 'Parse Error', code: 'HPE_INVALID_METHOD' }); - assertDowngraded(interceptor.downgradeIfHTTPSWhenHTTP(event)); + assertDowngraded(interceptor.downgradeIfHTTPSWhenHTTP(event)!); }); it('ignores non events', () => { @@ -150,7 +152,7 @@ describe('server logging LogInterceptor', () => { '4584650176:error:1408F09C:SSL routines:ssl3_get_record:http request:../deps/openssl/openssl/ssl/record/ssl3_record.c:322:\n'; const interceptor = new LogInterceptor(); const event = stubClientErrorEvent({ message }); - assertDowngraded(interceptor.downgradeIfHTTPWhenHTTPS(event)); + assertDowngraded(interceptor.downgradeIfHTTPWhenHTTPS(event)!); }); it('ignores non events', () => { diff --git a/src/legacy/server/logging/log_interceptor.js b/packages/kbn-legacy-logging/src/log_interceptor.ts similarity index 81% rename from src/legacy/server/logging/log_interceptor.js rename to packages/kbn-legacy-logging/src/log_interceptor.ts index 2298d83aa28571..2d559dc1ef55cb 100644 --- a/src/legacy/server/logging/log_interceptor.js +++ b/packages/kbn-legacy-logging/src/log_interceptor.ts @@ -19,6 +19,7 @@ import Stream from 'stream'; import { get, isEqual } from 'lodash'; +import { AnyEvent } from './log_events'; /** * Matches error messages when clients connect via HTTP instead of HTTPS; see unit test for full message. Warning: this can change when Node @@ -26,25 +27,32 @@ import { get, isEqual } from 'lodash'; */ const OPENSSL_GET_RECORD_REGEX = /ssl3_get_record:http/; -function doTagsMatch(event, tags) { - return isEqual(get(event, 'tags'), tags); +function doTagsMatch(event: AnyEvent, tags: string[]) { + return isEqual(event.tags, tags); } -function doesMessageMatch(errorMessage, match) { - if (!errorMessage) return false; - const isRegExp = match instanceof RegExp; - if (isRegExp) return match.test(errorMessage); +function doesMessageMatch(errorMessage: string, match: RegExp | string) { + if (!errorMessage) { + return false; + } + if (match instanceof RegExp) { + return match.test(errorMessage); + } return errorMessage === match; } // converts the given event into a debug log if it's an error of the given type -function downgradeIfErrorType(errorType, event) { +function downgradeIfErrorType(errorType: string, event: AnyEvent) { const isClientError = doTagsMatch(event, ['connection', 'client', 'error']); - if (!isClientError) return null; + if (!isClientError) { + return null; + } const matchesErrorType = get(event, 'error.code') === errorType || get(event, 'error.errno') === errorType; - if (!matchesErrorType) return null; + if (!matchesErrorType) { + return null; + } const errorTypeTag = errorType.toLowerCase(); @@ -57,12 +65,14 @@ function downgradeIfErrorType(errorType, event) { }; } -function downgradeIfErrorMessage(match, event) { +function downgradeIfErrorMessage(match: RegExp | string, event: AnyEvent) { const isClientError = doTagsMatch(event, ['connection', 'client', 'error']); const errorMessage = get(event, 'error.message'); const matchesErrorMessage = isClientError && doesMessageMatch(errorMessage, match); - if (!matchesErrorMessage) return null; + if (!matchesErrorMessage) { + return null; + } return { event: 'log', @@ -91,7 +101,7 @@ export class LogInterceptor extends Stream.Transform { * * @param {object} - log event */ - downgradeIfEconnreset(event) { + downgradeIfEconnreset(event: AnyEvent) { return downgradeIfErrorType('ECONNRESET', event); } @@ -105,7 +115,7 @@ export class LogInterceptor extends Stream.Transform { * * @param {object} - log event */ - downgradeIfEpipe(event) { + downgradeIfEpipe(event: AnyEvent) { return downgradeIfErrorType('EPIPE', event); } @@ -119,19 +129,19 @@ export class LogInterceptor extends Stream.Transform { * * @param {object} - log event */ - downgradeIfEcanceled(event) { + downgradeIfEcanceled(event: AnyEvent) { return downgradeIfErrorType('ECANCELED', event); } - downgradeIfHTTPSWhenHTTP(event) { + downgradeIfHTTPSWhenHTTP(event: AnyEvent) { return downgradeIfErrorType('HPE_INVALID_METHOD', event); } - downgradeIfHTTPWhenHTTPS(event) { + downgradeIfHTTPWhenHTTPS(event: AnyEvent) { return downgradeIfErrorMessage(OPENSSL_GET_RECORD_REGEX, event); } - _transform(event, enc, next) { + _transform(event: AnyEvent, enc: string, next: Stream.TransformCallback) { const downgraded = this.downgradeIfEconnreset(event) || this.downgradeIfEpipe(event) || diff --git a/src/legacy/server/logging/log_reporter.js b/packages/kbn-legacy-logging/src/log_reporter.ts similarity index 64% rename from src/legacy/server/logging/log_reporter.js rename to packages/kbn-legacy-logging/src/log_reporter.ts index 4afb00b568844b..8ecaf348bac04c 100644 --- a/src/legacy/server/logging/log_reporter.js +++ b/packages/kbn-legacy-logging/src/log_reporter.ts @@ -17,27 +17,21 @@ * under the License. */ +// @ts-expect-error missing type def import { Squeeze } from '@hapi/good-squeeze'; -import { createWriteStream as writeStr } from 'fs'; +import { createWriteStream as writeStr, WriteStream } from 'fs'; -import LogFormatJson from './log_format_json'; -import LogFormatString from './log_format_string'; +import { KbnLoggerJsonFormat } from './log_format_json'; +import { KbnLoggerStringFormat } from './log_format_string'; import { LogInterceptor } from './log_interceptor'; +import { LogFormatConfig } from './log_format'; -// NOTE: legacy logger creates a new stream for each new access -// In https://github.com/elastic/kibana/pull/55937 we reach the max listeners -// default limit of 10 for process.stdout which starts a long warning/error -// thrown every time we start the server. -// In order to keep using the legacy logger until we remove it I'm just adding -// a new hard limit here. -process.stdout.setMaxListeners(25); - -export function getLoggerStream({ events, config }) { +export function getLogReporter({ events, config }: { events: any; config: LogFormatConfig }) { const squeeze = new Squeeze(events); - const format = config.json ? new LogFormatJson(config) : new LogFormatString(config); + const format = config.json ? new KbnLoggerJsonFormat(config) : new KbnLoggerStringFormat(config); const logInterceptor = new LogInterceptor(); - let dest; + let dest: WriteStream | NodeJS.WritableStream; if (config.dest === 'stdout') { dest = process.stdout; } else { diff --git a/src/legacy/server/logging/log_with_metadata.js b/packages/kbn-legacy-logging/src/metadata.ts similarity index 55% rename from src/legacy/server/logging/log_with_metadata.js rename to packages/kbn-legacy-logging/src/metadata.ts index 73e03a154907ac..8b7c2f8f87c590 100644 --- a/src/legacy/server/logging/log_with_metadata.js +++ b/packages/kbn-legacy-logging/src/metadata.ts @@ -16,30 +16,38 @@ * specific language governing permissions and limitations * under the License. */ + import { isPlainObject } from 'lodash'; -import { - metadataSymbol, - attachMetaData, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../src/core/server/legacy/logging/legacy_logging_server'; +export const metadataSymbol = Symbol('log message with metadata'); -export const logWithMetadata = { - isLogEvent(eventData) { - return Boolean(isPlainObject(eventData) && eventData[metadataSymbol]); - }, +export interface EventData { + [metadataSymbol]?: EventMetadata; + [key: string]: any; +} - getLogEventData(eventData) { - const { message, metadata } = eventData[metadataSymbol]; - return { - ...metadata, - message, - }; - }, +export interface EventMetadata { + message: string; + metadata: Record; +} + +export const isEventData = (eventData: EventData) => { + return Boolean(isPlainObject(eventData) && eventData[metadataSymbol]); +}; - decorateServer(server) { - server.decorate('server', 'logWithMetadata', (tags, message, metadata = {}) => { - server.log(tags, attachMetaData(message, metadata)); - }); - }, +export const getLogEventData = (eventData: EventData) => { + const { message, metadata } = eventData[metadataSymbol]!; + return { + ...metadata, + message, + }; +}; + +export const attachMetaData = (message: string, metadata: Record = {}) => { + return { + [metadataSymbol]: { + message, + metadata, + }, + }; }; diff --git a/src/legacy/server/logging/rotate/index.ts b/packages/kbn-legacy-logging/src/rotate/index.ts similarity index 92% rename from src/legacy/server/logging/rotate/index.ts rename to packages/kbn-legacy-logging/src/rotate/index.ts index d6b7cfa76f9ee4..2387fc530e58bf 100644 --- a/src/legacy/server/logging/rotate/index.ts +++ b/packages/kbn-legacy-logging/src/rotate/index.ts @@ -20,13 +20,13 @@ import { isMaster, isWorker } from 'cluster'; import { Server } from '@hapi/hapi'; import { LogRotator } from './log_rotator'; -import { KibanaConfig } from '../../kbn_server'; +import { LegacyLoggingConfig } from '../schema'; let logRotator: LogRotator; -export async function setupLoggingRotate(server: Server, config: KibanaConfig) { +export async function setupLoggingRotate(server: Server, config: LegacyLoggingConfig) { // If log rotate is not enabled we skip - if (!config.get('logging.rotate.enabled')) { + if (!config.rotate.enabled) { return; } @@ -38,7 +38,7 @@ export async function setupLoggingRotate(server: Server, config: KibanaConfig) { // We don't want to run logging rotate server if // we are not logging to a file - if (config.get('logging.dest') === 'stdout') { + if (config.dest === 'stdout') { server.log( ['warning', 'logging:rotate'], 'Log rotation is enabled but logging.dest is configured for stdout. Set logging.dest to a file for this setting to take effect.' diff --git a/src/legacy/server/logging/rotate/log_rotator.test.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts similarity index 93% rename from src/legacy/server/logging/rotate/log_rotator.test.ts rename to packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts index 8f67b47f6949e0..1f6407d2cca30d 100644 --- a/src/legacy/server/logging/rotate/log_rotator.test.ts +++ b/packages/kbn-legacy-logging/src/rotate/log_rotator.test.ts @@ -19,10 +19,10 @@ import del from 'del'; import fs, { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; -import { LogRotator } from './log_rotator'; import { tmpdir } from 'os'; import { dirname, join } from 'path'; -import lodash from 'lodash'; +import { LogRotator } from './log_rotator'; +import { LegacyLoggingConfig } from '../schema'; const mockOn = jest.fn(); jest.mock('chokidar', () => ({ @@ -32,19 +32,26 @@ jest.mock('chokidar', () => ({ })), })); -lodash.throttle = (fn: any) => fn; +jest.mock('lodash', () => ({ + ...(jest.requireActual('lodash') as any), + throttle: (fn: any) => fn, +})); const tempDir = join(tmpdir(), 'kbn_log_rotator_test'); const testFilePath = join(tempDir, 'log_rotator_test_log_file.log'); -const createLogRotatorConfig: any = (logFilePath: string) => { - return new Map([ - ['logging.dest', logFilePath], - ['logging.rotate.everyBytes', 2], - ['logging.rotate.keepFiles', 2], - ['logging.rotate.usePolling', false], - ['logging.rotate.pollingInterval', 10000], - ] as any); +const createLogRotatorConfig = (logFilePath: string): LegacyLoggingConfig => { + return { + dest: logFilePath, + rotate: { + enabled: true, + keepFiles: 2, + everyBytes: 2, + usePolling: false, + pollingInterval: 10000, + pollingPolicyTestTimeout: 4000, + }, + } as LegacyLoggingConfig; }; const mockServer: any = { @@ -62,7 +69,7 @@ describe('LogRotator', () => { }); afterEach(() => { - del.sync(dirname(testFilePath), { force: true }); + del.sync(tempDir, { force: true }); mockOn.mockClear(); }); @@ -71,14 +78,14 @@ describe('LogRotator', () => { const logRotator = new LogRotator(createLogRotatorConfig(testFilePath), mockServer); jest.spyOn(logRotator, '_sendReloadLogConfigSignal').mockImplementation(() => {}); + await logRotator.start(); expect(logRotator.running).toBe(true); await logRotator.stop(); - const testLogFileDir = dirname(testFilePath); - expect(existsSync(join(testLogFileDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); + expect(existsSync(join(tempDir, 'log_rotator_test_log_file.log.0'))).toBeTruthy(); }); it('rotates log file when equal than set limit over time', async () => { diff --git a/src/legacy/server/logging/rotate/log_rotator.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts similarity index 94% rename from src/legacy/server/logging/rotate/log_rotator.ts rename to packages/kbn-legacy-logging/src/rotate/log_rotator.ts index c4054b2daed456..54181e30d6007b 100644 --- a/src/legacy/server/logging/rotate/log_rotator.ts +++ b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts @@ -27,7 +27,7 @@ import { basename, dirname, join, sep } from 'path'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { promisify } from 'util'; -import { KibanaConfig } from '../../kbn_server'; +import { LegacyLoggingConfig } from '../schema'; const mkdirAsync = promisify(fs.mkdir); const readdirAsync = promisify(fs.readdir); @@ -37,7 +37,7 @@ const unlinkAsync = promisify(fs.unlink); const writeFileAsync = promisify(fs.writeFile); export class LogRotator { - private readonly config: KibanaConfig; + private readonly config: LegacyLoggingConfig; private readonly log: Server['log']; public logFilePath: string; public everyBytes: number; @@ -52,19 +52,19 @@ export class LogRotator { private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; public shouldUsePolling: boolean; - constructor(config: KibanaConfig, server: Server) { + constructor(config: LegacyLoggingConfig, server: Server) { this.config = config; this.log = server.log.bind(server); - this.logFilePath = config.get('logging.dest'); - this.everyBytes = config.get('logging.rotate.everyBytes'); - this.keepFiles = config.get('logging.rotate.keepFiles'); + this.logFilePath = config.dest; + this.everyBytes = config.rotate.everyBytes; + this.keepFiles = config.rotate.keepFiles; this.running = false; this.logFileSize = 0; this.isRotating = false; this.throttledRotate = throttle(async () => await this._rotate(), 5000); this.stalker = null; - this.usePolling = config.get('logging.rotate.usePolling'); - this.pollingInterval = config.get('logging.rotate.pollingInterval'); + this.usePolling = config.rotate.usePolling; + this.pollingInterval = config.rotate.pollingInterval; this.shouldUsePolling = false; this.stalkerUsePollingPolicyTestTimeout = null; } @@ -128,7 +128,10 @@ export class LogRotator { }; // setup conditions that would fire the observable - this.stalkerUsePollingPolicyTestTimeout = setTimeout(() => completeFn(true), 15000); + this.stalkerUsePollingPolicyTestTimeout = setTimeout( + () => completeFn(true), + this.config.rotate.pollingPolicyTestTimeout || 15000 + ); testWatcher.on('change', () => completeFn(false)); testWatcher.on('error', () => completeFn(true)); @@ -152,7 +155,7 @@ export class LogRotator { } async _startLogFileSizeMonitor() { - this.usePolling = this.config.get('logging.rotate.usePolling'); + this.usePolling = this.config.rotate.usePolling; this.shouldUsePolling = await this._shouldUsePolling(); if (this.usePolling && !this.shouldUsePolling) { diff --git a/packages/kbn-legacy-logging/src/schema.ts b/packages/kbn-legacy-logging/src/schema.ts new file mode 100644 index 00000000000000..5f0e4fe89422b8 --- /dev/null +++ b/packages/kbn-legacy-logging/src/schema.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Joi from 'joi'; + +const HANDLED_IN_KIBANA_PLATFORM = Joi.any().description( + 'This key is handled in the new platform ONLY' +); + +export interface LegacyLoggingConfig { + silent: boolean; + quiet: boolean; + verbose: boolean; + events: Record; + dest: string; + filter: Record; + json: boolean; + timezone?: string; + rotate: { + enabled: boolean; + everyBytes: number; + keepFiles: number; + pollingInterval: number; + usePolling: boolean; + pollingPolicyTestTimeout?: number; + }; +} + +export const legacyLoggingConfigSchema = Joi.object() + .keys({ + appenders: HANDLED_IN_KIBANA_PLATFORM, + loggers: HANDLED_IN_KIBANA_PLATFORM, + root: HANDLED_IN_KIBANA_PLATFORM, + + silent: Joi.boolean().default(false), + + quiet: Joi.boolean().when('silent', { + is: true, + then: Joi.boolean().default(true).valid(true), + otherwise: Joi.boolean().default(false), + }), + + verbose: Joi.boolean().when('quiet', { + is: true, + then: Joi.valid(false).default(false), + otherwise: Joi.boolean().default(false), + }), + events: Joi.any().default({}), + dest: Joi.string().default('stdout'), + filter: Joi.any().default({}), + json: Joi.boolean().when('dest', { + is: 'stdout', + then: Joi.boolean().default(!process.stdout.isTTY), + otherwise: Joi.boolean().default(true), + }), + timezone: Joi.string(), + rotate: Joi.object() + .keys({ + enabled: Joi.boolean().default(false), + everyBytes: Joi.number() + // > 1MB + .greater(1048576) + // < 1GB + .less(1073741825) + // 10MB + .default(10485760), + keepFiles: Joi.number().greater(2).less(1024).default(7), + pollingInterval: Joi.number().greater(5000).less(3600000).default(10000), + usePolling: Joi.boolean().default(false), + }) + .default(), + }) + .default(); diff --git a/packages/kbn-legacy-logging/src/setup_logging.ts b/packages/kbn-legacy-logging/src/setup_logging.ts new file mode 100644 index 00000000000000..103e81249a136d --- /dev/null +++ b/packages/kbn-legacy-logging/src/setup_logging.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// @ts-expect-error missing typedef +import good from '@elastic/good'; +import { Server } from '@hapi/hapi'; +import { LegacyLoggingConfig } from './schema'; +import { getLoggingConfiguration } from './get_logging_config'; + +export async function setupLogging( + server: Server, + config: LegacyLoggingConfig, + opsInterval: number +) { + // NOTE: legacy logger creates a new stream for each new access + // In https://github.com/elastic/kibana/pull/55937 we reach the max listeners + // default limit of 10 for process.stdout which starts a long warning/error + // thrown every time we start the server. + // In order to keep using the legacy logger until we remove it I'm just adding + // a new hard limit here. + process.stdout.setMaxListeners(25); + + return await server.register({ + plugin: good, + options: getLoggingConfiguration(config, opsInterval), + }); +} + +export function reconfigureLogging( + server: Server, + config: LegacyLoggingConfig, + opsInterval: number +) { + const loggingOptions = getLoggingConfiguration(config, opsInterval); + (server.plugins as any)['@elastic/good'].reconfigure(loggingOptions); +} diff --git a/packages/kbn-legacy-logging/src/test_utils/index.ts b/packages/kbn-legacy-logging/src/test_utils/index.ts new file mode 100644 index 00000000000000..f13c869b563a29 --- /dev/null +++ b/packages/kbn-legacy-logging/src/test_utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createListStream, createPromiseFromStreams } from './streams'; diff --git a/packages/kbn-legacy-logging/src/test_utils/streams.ts b/packages/kbn-legacy-logging/src/test_utils/streams.ts new file mode 100644 index 00000000000000..0f37a13f8a478b --- /dev/null +++ b/packages/kbn-legacy-logging/src/test_utils/streams.ts @@ -0,0 +1,96 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { pipeline, Writable, Readable } from 'stream'; + +/** + * Create a Readable stream that provides the items + * from a list as objects to subscribers + * + * @param {Array} items - the list of items to provide + * @return {Readable} + */ +export function createListStream(items: T | T[] = []) { + const queue = Array.isArray(items) ? [...items] : [items]; + + return new Readable({ + objectMode: true, + read(size) { + queue.splice(0, size).forEach((item) => { + this.push(item); + }); + + if (!queue.length) { + this.push(null); + } + }, + }); +} + +/** + * Take an array of streams, pipe the output + * from each one into the next, listening for + * errors from any of the streams, and then resolve + * the promise once the final stream has finished + * writing/reading. + * + * If the last stream is readable, it's final value + * will be provided as the promise value. + * + * Errors emitted from any stream will cause + * the promise to be rejected with that error. + * + * @param {Array} streams + * @return {Promise} + */ + +function isReadable(stream: Readable | Writable): stream is Readable { + return 'read' in stream && typeof stream.read === 'function'; +} + +export async function createPromiseFromStreams(streams: [Readable, ...Writable[]]): Promise { + let finalChunk: any; + const last = streams[streams.length - 1]; + if (!isReadable(last) && streams.length === 1) { + // For a nicer error than what stream.pipeline throws + throw new Error('A minimum of 2 streams is required when a non-readable stream is given'); + } + if (isReadable(last)) { + // We are pushing a writable stream to capture the last chunk + streams.push( + new Writable({ + // Use object mode even when "last" stream isn't. This allows to + // capture the last chunk as-is. + objectMode: true, + write(chunk, enc, done) { + finalChunk = chunk; + done(); + }, + }) + ); + } + + return new Promise((resolve, reject) => { + // @ts-expect-error 'pipeline' doesn't support variable length of arguments + pipeline(...streams, (err) => { + if (err) return reject(err); + resolve(finalChunk); + }); + }); +} diff --git a/src/legacy/server/logging/apply_filters_to_keys.test.js b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts similarity index 96% rename from src/legacy/server/logging/apply_filters_to_keys.test.js rename to packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts index e007157e9488b6..bfcc7b1c908d4a 100644 --- a/src/legacy/server/logging/apply_filters_to_keys.test.js +++ b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import applyFiltersToKeys from './apply_filters_to_keys'; +import { applyFiltersToKeys } from './apply_filters_to_keys'; describe('applyFiltersToKeys(obj, actionsByKey)', function () { it('applies for each key+prop in actionsByKey', function () { diff --git a/src/legacy/server/logging/apply_filters_to_keys.js b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts similarity index 83% rename from src/legacy/server/logging/apply_filters_to_keys.js rename to packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts index 63e5ab4c62f298..8fd7eac57fc32e 100644 --- a/src/legacy/server/logging/apply_filters_to_keys.js +++ b/packages/kbn-legacy-logging/src/utils/apply_filters_to_keys.ts @@ -17,15 +17,15 @@ * under the License. */ -function toPojo(obj) { +function toPojo(obj: Record) { return JSON.parse(JSON.stringify(obj)); } -function replacer(match, group) { +function replacer(match: string, group: any[]) { return new Array(group.length + 1).join('X'); } -function apply(obj, key, action) { +function apply(obj: Record, key: string, action: string) { for (const k in obj) { if (obj.hasOwnProperty(k)) { let val = obj[k]; @@ -44,14 +44,17 @@ function apply(obj, key, action) { } } } else if (typeof val === 'object') { - val = apply(val, key, action); + val = apply(val as Record, key, action); } } } return obj; } -export default function (obj, actionsByKey) { +export function applyFiltersToKeys( + obj: Record, + actionsByKey: Record +) { return Object.keys(actionsByKey).reduce((output, key) => { return apply(output, key, actionsByKey[key]); }, toPojo(obj)); diff --git a/packages/kbn-legacy-logging/src/utils/index.ts b/packages/kbn-legacy-logging/src/utils/index.ts new file mode 100644 index 00000000000000..5841e7b6082843 --- /dev/null +++ b/packages/kbn-legacy-logging/src/utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { applyFiltersToKeys } from './apply_filters_to_keys'; diff --git a/packages/kbn-legacy-logging/tsconfig.json b/packages/kbn-legacy-logging/tsconfig.json new file mode 100644 index 00000000000000..8fd202a2dce8ba --- /dev/null +++ b/packages/kbn-legacy-logging/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target", + "stripInternal": false, + "declaration": true, + "declarationMap": true, + "types": ["jest", "node"] + }, + "include": ["./src/**/*"] +} diff --git a/packages/kbn-legacy-logging/yarn.lock b/packages/kbn-legacy-logging/yarn.lock new file mode 120000 index 00000000000000..3f82ebc9cdbae3 --- /dev/null +++ b/packages/kbn-legacy-logging/yarn.lock @@ -0,0 +1 @@ +../../yarn.lock \ No newline at end of file diff --git a/packages/kbn-optimizer/src/node/cache.ts b/packages/kbn-optimizer/src/node/cache.ts index e918bae86c835c..417e38d5fb7abb 100644 --- a/packages/kbn-optimizer/src/node/cache.ts +++ b/packages/kbn-optimizer/src/node/cache.ts @@ -17,91 +17,67 @@ * under the License. */ -import Path from 'path'; -import Fs from 'fs'; +import { Writable } from 'stream'; -// @ts-expect-error no types available +import chalk from 'chalk'; import * as LmdbStore from 'lmdb-store'; -import { REPO_ROOT, UPSTREAM_BRANCH } from '@kbn/dev-utils'; - -const LMDB_PKG = JSON.parse( - Fs.readFileSync(Path.resolve(REPO_ROOT, 'node_modules/lmdb-store/package.json'), 'utf8') -); -const CACHE_DIR = Path.resolve( - REPO_ROOT, - `data/node_auto_transpilation_cache/lmdb-${LMDB_PKG.version}/${UPSTREAM_BRANCH}` -); - -const reportError = () => { - // right now I'm not sure we need to worry about errors, the cache isn't actually - // necessary, and if the cache is broken it should just rebuild on the next restart - // of the process. We don't know how often errors occur though and what types of - // things might fail on different machines so we probably want some way to signal - // to users that something is wrong -}; const GLOBAL_ATIME = `${Date.now()}`; const MINUTE = 1000 * 60; const HOUR = MINUTE * 60; const DAY = HOUR * 24; -interface Lmdb { - name: string; - get(key: string): T | undefined; - put(key: string, value: T, version?: number, ifVersion?: number): Promise; - remove(key: string, ifVersion?: number): Promise; - removeSync(key: string): void; - openDB(options: { - name: string; - encoding: 'msgpack' | 'string' | 'json' | 'binary'; - }): Lmdb; - getRange(options?: { - start?: T; - end?: T; - reverse?: boolean; - limit?: number; - versions?: boolean; - }): Iterable<{ key: string; value: T }>; -} +const dbName = (db: LmdbStore.Database) => + // @ts-expect-error db.name is not a documented/typed property + db.name; export class Cache { - private readonly codes: Lmdb; - private readonly atimes: Lmdb; - private readonly mtimes: Lmdb; - private readonly sourceMaps: Lmdb; + private readonly codes: LmdbStore.RootDatabase; + private readonly atimes: LmdbStore.Database; + private readonly mtimes: LmdbStore.Database; + private readonly sourceMaps: LmdbStore.Database; private readonly prefix: string; + private readonly log?: Writable; + private readonly timer: NodeJS.Timer; - constructor(config: { prefix: string }) { + constructor(config: { dir: string; prefix: string; log?: Writable }) { this.prefix = config.prefix; + this.log = config.log; - this.codes = LmdbStore.open({ + this.codes = LmdbStore.open(config.dir, { name: 'codes', - path: CACHE_DIR, + encoding: 'string', maxReaders: 500, }); - this.atimes = this.codes.openDB({ + // TODO: redundant 'name' syntax is necessary because of a bug that I have yet to fix + this.atimes = this.codes.openDB('atimes', { name: 'atimes', encoding: 'string', }); - this.mtimes = this.codes.openDB({ + this.mtimes = this.codes.openDB('mtimes', { name: 'mtimes', encoding: 'string', }); - this.sourceMaps = this.codes.openDB({ + this.sourceMaps = this.codes.openDB('sourceMaps', { name: 'sourceMaps', - encoding: 'msgpack', + encoding: 'string', }); // after the process has been running for 30 minutes prune the // keys which haven't been used in 30 days. We use `unref()` to // make sure this timer doesn't hold other processes open // unexpectedly - setTimeout(() => { + this.timer = setTimeout(() => { this.pruneOldKeys(); - }, 30 * MINUTE).unref(); + }, 30 * MINUTE); + + // timer.unref is not defined in jest which emulates the dom by default + if (typeof this.timer.unref === 'function') { + this.timer.unref(); + } } getMtime(path: string) { @@ -110,45 +86,78 @@ export class Cache { getCode(path: string) { const key = this.getKey(path); + const code = this.safeGet(this.codes, key); - // when we use a file from the cache set the "atime" of that cache entry - // so that we know which cache items we use and which haven't been - // touched in a long time (currently 30 days) - this.atimes.put(key, GLOBAL_ATIME).catch(reportError); + if (code !== undefined) { + // when we use a file from the cache set the "atime" of that cache entry + // so that we know which cache items we use and which haven't been + // touched in a long time (currently 30 days) + this.safePut(this.atimes, key, GLOBAL_ATIME); + } - return this.safeGet(this.codes, key); + return code; } getSourceMap(path: string) { - return this.safeGet(this.sourceMaps, this.getKey(path)); + const map = this.safeGet(this.sourceMaps, this.getKey(path)); + if (typeof map === 'string') { + return JSON.parse(map); + } } - update(path: string, file: { mtime: string; code: string; map: any }) { + async update(path: string, file: { mtime: string; code: string; map: any }) { const key = this.getKey(path); - Promise.all([ - this.atimes.put(key, GLOBAL_ATIME), - this.mtimes.put(key, file.mtime), - this.codes.put(key, file.code), - this.sourceMaps.put(key, file.map), - ]).catch(reportError); + await Promise.all([ + this.safePut(this.atimes, key, GLOBAL_ATIME), + this.safePut(this.mtimes, key, file.mtime), + this.safePut(this.codes, key, file.code), + this.safePut(this.sourceMaps, key, JSON.stringify(file.map)), + ]); + } + + close() { + clearTimeout(this.timer); } private getKey(path: string) { return `${this.prefix}${path}`; } - private safeGet(db: Lmdb, key: string) { + private safeGet(db: LmdbStore.Database, key: string) { try { - return db.get(key); + const value = db.get(key); + this.debug(value === undefined ? 'MISS' : 'HIT', db, key); + return value; } catch (error) { - process.stderr.write( - `failed to read node transpilation [${db.name}] cache for [${key}]: ${error.stack}\n` - ); - db.removeSync(key); + this.logError('GET', db, key, error); } } + private async safePut(db: LmdbStore.Database, key: string, value: V) { + try { + await db.put(key, value); + this.debug('PUT', db, key); + } catch (error) { + this.logError('PUT', db, key, error); + } + } + + private debug(type: string, db: LmdbStore.Database, key: LmdbStore.Key) { + if (this.log) { + this.log.write(`${type} [${dbName(db)}] ${String(key)}\n`); + } + } + + private logError(type: 'GET' | 'PUT', db: LmdbStore.Database, key: LmdbStore.Key, error: Error) { + this.debug(`ERROR/${type}`, db, `${String(key)}: ${error.stack}`); + process.stderr.write( + chalk.red( + `[@kbn/optimizer/node] ${type} error [${dbName(db)}/${String(key)}]: ${error.stack}\n` + ) + ); + } + private async pruneOldKeys() { try { const ATIME_LIMIT = Date.now() - 30 * DAY; @@ -157,9 +166,10 @@ export class Cache { const validKeys: string[] = []; const invalidKeys: string[] = []; + // @ts-expect-error See https://github.com/DoctorEvidence/lmdb-store/pull/18 for (const { key, value } of this.atimes.getRange()) { - const atime = parseInt(value, 10); - if (atime < ATIME_LIMIT) { + const atime = parseInt(`${value}`, 10); + if (Number.isNaN(atime) || atime < ATIME_LIMIT) { invalidKeys.push(key); } else { validKeys.push(key); diff --git a/packages/kbn-optimizer/src/node/integration_tests/cache.test.ts b/packages/kbn-optimizer/src/node/integration_tests/cache.test.ts new file mode 100644 index 00000000000000..c860164d4306af --- /dev/null +++ b/packages/kbn-optimizer/src/node/integration_tests/cache.test.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; +import { Writable } from 'stream'; + +import del from 'del'; + +import { Cache } from '../cache'; + +const DIR = Path.resolve(__dirname, '../__tmp__/cache'); + +const makeTestLog = () => { + const log = Object.assign( + new Writable({ + write(chunk, enc, cb) { + log.output += chunk; + cb(); + }, + }), + { + output: '', + } + ); + + return log; +}; + +const instances: Cache[] = []; +const makeCache = (...options: ConstructorParameters) => { + const instance = new Cache(...options); + instances.push(instance); + return instance; +}; + +beforeEach(async () => await del(DIR)); +afterEach(async () => { + await del(DIR); + for (const instance of instances) { + instance.close(); + } + instances.length = 0; +}); + +it('returns undefined until values are set', async () => { + const path = '/foo/bar.js'; + const mtime = new Date().toJSON(); + const log = makeTestLog(); + const cache = makeCache({ + dir: DIR, + prefix: 'foo', + log, + }); + + expect(cache.getMtime(path)).toBe(undefined); + expect(cache.getCode(path)).toBe(undefined); + expect(cache.getSourceMap(path)).toBe(undefined); + + await cache.update(path, { + mtime, + code: 'var x = 1', + map: { foo: 'bar' }, + }); + + expect(cache.getMtime(path)).toBe(mtime); + expect(cache.getCode(path)).toBe('var x = 1'); + expect(cache.getSourceMap(path)).toEqual({ foo: 'bar' }); + expect(log.output).toMatchInlineSnapshot(` + "MISS [mtimes] foo/foo/bar.js + MISS [codes] foo/foo/bar.js + MISS [sourceMaps] foo/foo/bar.js + PUT [atimes] foo/foo/bar.js + PUT [mtimes] foo/foo/bar.js + PUT [codes] foo/foo/bar.js + PUT [sourceMaps] foo/foo/bar.js + HIT [mtimes] foo/foo/bar.js + HIT [codes] foo/foo/bar.js + HIT [sourceMaps] foo/foo/bar.js + " + `); +}); diff --git a/packages/kbn-optimizer/src/node/node_auto_tranpilation.ts b/packages/kbn-optimizer/src/node/node_auto_tranpilation.ts index ff6ab1c68da532..cc532941094123 100644 --- a/packages/kbn-optimizer/src/node/node_auto_tranpilation.ts +++ b/packages/kbn-optimizer/src/node/node_auto_tranpilation.ts @@ -39,7 +39,7 @@ import Crypto from 'crypto'; import * as babel from '@babel/core'; import { addHook } from 'pirates'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT, UPSTREAM_BRANCH } from '@kbn/dev-utils'; import sourceMapSupport from 'source-map-support'; import { Cache } from './cache'; @@ -134,7 +134,13 @@ export function registerNodeAutoTranspilation() { installed = true; const cache = new Cache({ + dir: Path.resolve(REPO_ROOT, 'data/node_auto_transpilation_cache_v2', UPSTREAM_BRANCH), prefix: determineCachePrefix(), + log: process.env.DEBUG_NODE_TRANSPILER_CACHE + ? Fs.createWriteStream(Path.resolve(REPO_ROOT, 'node_auto_transpilation_cache.log'), { + flags: 'a', + }) + : undefined, }); sourceMapSupport.install({ diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 4a85eca206c965..f899a5b44ab6c1 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -108,4 +108,14 @@ module.exports = { '[/\\\\]node_modules(?![\\/\\\\]monaco-editor)[/\\\\].+\\.js$', 'packages/kbn-pm/dist/index.js', ], + + // An array of regexp pattern strings that are matched against all source file paths, matched files to include/exclude for code coverage + collectCoverageFrom: [ + '**/*.{js,mjs,jsx,ts,tsx}', + '!**/{__test__,__snapshots__,__examples__,mocks,tests,test_helpers,integration_tests,types}/**/*', + '!**/*mock*.ts', + '!**/*.test.ts', + '!**/*.d.ts', + '!**/index.{js,ts}', + ], }; diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts index 5bbc72fe04e86e..910c9ad2467008 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts @@ -19,7 +19,7 @@ import dedent from 'dedent'; -import { createFailureIssue, updateFailureIssue } from './report_failure'; +import { createFailureIssue, getCiType, updateFailureIssue } from './report_failure'; jest.mock('./github_api'); const { GithubApi } = jest.requireMock('./github_api'); @@ -51,7 +51,7 @@ describe('createFailureIssue()', () => { this is the failure text \`\`\` - First failure: [Jenkins Build](https://build-url) + First failure: [${getCiType()} Build](https://build-url) ", Array [ @@ -111,7 +111,7 @@ describe('updateFailureIssue()', () => { "calls": Array [ Array [ 1234, - "New failure: [Jenkins Build](https://build-url)", + "New failure: [${getCiType()} Build](https://build-url)", ], ], "results": Array [ diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts index 1413d054984594..30ec6ab939560a 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts @@ -21,6 +21,10 @@ import { TestFailure } from './get_failures'; import { GithubIssueMini, GithubApi } from './github_api'; import { getIssueMetadata, updateIssueMetadata } from './issue_metadata'; +export function getCiType() { + return process.env.TEAMCITY_CI ? 'TeamCity' : 'Jenkins'; +} + export async function createFailureIssue(buildUrl: string, failure: TestFailure, api: GithubApi) { const title = `Failing test: ${failure.classname} - ${failure.name}`; @@ -32,7 +36,7 @@ export async function createFailureIssue(buildUrl: string, failure: TestFailure, failure.failure, '```', '', - `First failure: [Jenkins Build](${buildUrl})`, + `First failure: [${getCiType()} Build](${buildUrl})`, ].join('\n'), { 'test.class': failure.classname, @@ -52,7 +56,7 @@ export async function updateFailureIssue(buildUrl: string, issue: GithubIssueMin }); await api.editIssueBodyAndEnsureOpen(issue.number, newBody); - await api.addIssueComment(issue.number, `New failure: [Jenkins Build](${buildUrl})`); + await api.addIssueComment(issue.number, `New failure: [${getCiType()} Build](${buildUrl})`); return newCount; } diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 93616ce78a04a5..9010e324bb392b 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -33,6 +33,17 @@ import { getReportMessageIter } from './report_metadata'; const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; +const getBranch = () => { + if (process.env.TEAMCITY_CI) { + return (process.env.GIT_BRANCH || '').replace(/^refs\/heads\//, ''); + } else { + // JOB_NAME is formatted as `elastic+kibana+7.x` in some places and `elastic+kibana+7.x/JOB=kibana-intake,node=immutable` in others + const jobNameSplit = (process.env.JOB_NAME || '').split(/\+|\//); + const branch = jobNameSplit.length >= 3 ? jobNameSplit[2] : process.env.GIT_BRANCH; + return branch; + } +}; + export function runFailedTestsReporterCli() { run( async ({ log, flags }) => { @@ -44,16 +55,15 @@ export function runFailedTestsReporterCli() { } if (updateGithub) { - // JOB_NAME is formatted as `elastic+kibana+7.x` in some places and `elastic+kibana+7.x/JOB=kibana-intake,node=immutable` in others - const jobNameSplit = (process.env.JOB_NAME || '').split(/\+|\//); - const branch = jobNameSplit.length >= 3 ? jobNameSplit[2] : process.env.GIT_BRANCH; + const branch = getBranch(); if (!branch) { throw createFailError( 'Unable to determine originating branch from job name or other environment variables' ); } - const isPr = !!process.env.ghprbPullId; + // ghprbPullId check can be removed once there are no PR jobs running on Jenkins + const isPr = !!process.env.GITHUB_PR_NUMBER || !!process.env.ghprbPullId; const isMasterOrVersion = branch === 'master' || branch.match(/^\d+\.(x|\d+)$/); if (!isMasterOrVersion || isPr) { log.info('Failure issues only created on master/version branch jobs'); @@ -69,7 +79,9 @@ export function runFailedTestsReporterCli() { const buildUrl = flags['build-url'] || (updateGithub ? '' : 'http://buildUrl'); if (typeof buildUrl !== 'string' || !buildUrl) { - throw createFlagError('Missing --build-url or process.env.BUILD_URL'); + throw createFlagError( + 'Missing --build-url, process.env.TEAMCITY_BUILD_URL, or process.env.BUILD_URL' + ); } const patterns = flags._.length ? flags._ : DEFAULT_PATTERNS; @@ -161,12 +173,12 @@ export function runFailedTestsReporterCli() { default: { 'github-update': true, 'report-update': true, - 'build-url': process.env.BUILD_URL, + 'build-url': process.env.TEAMCITY_BUILD_URL || process.env.BUILD_URL, }, help: ` --no-github-update Execute the CLI without writing to Github --no-report-update Execute the CLI without writing to the JUnit reports - --build-url URL of the failed build, defaults to process.env.BUILD_URL + --build-url URL of the failed build, defaults to process.env.TEAMCITY_BUILD_URL or process.env.BUILD_URL `, }, } diff --git a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js index 407ab37123d5d4..605ad38efbc963 100644 --- a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js @@ -67,6 +67,7 @@ describe('dev/mocha/junit report generation', () => { expect(testsuite).to.eql({ $: { failures: '2', + name: 'test', skipped: '1', tests: '4', time: testsuite.$.time, diff --git a/packages/kbn-test/src/mocha/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.js index 84d488bd8b5a10..de28fceb967e29 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.js @@ -108,6 +108,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { ); const testsuitesEl = builder.ele('testsuite', { + name: reportName, timestamp: new Date(stats.startTime).toISOString().slice(0, -5), time: getDuration(stats), tests: allTests.length + failedHooks.length, diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts index 697e5bc37d6027..c4dca1b84f4eb8 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts @@ -17,10 +17,10 @@ * under the License. */ -jest.mock('../legacy_logging_server'); +jest.mock('@kbn/legacy-logging'); import { LogRecord, LogLevel } from '../../../logging'; -import { LegacyLoggingServer } from '../legacy_logging_server'; +import { LegacyLoggingServer } from '@kbn/legacy-logging'; import { LegacyAppender } from './legacy_appender'; afterEach(() => (LegacyLoggingServer as any).mockClear()); diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts index 67337c7d676297..286448231d23f1 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.ts @@ -18,8 +18,8 @@ */ import { schema } from '@kbn/config-schema'; -import { DisposableAppender, LogRecord } from '../../../logging'; -import { LegacyLoggingServer } from '../legacy_logging_server'; +import { LegacyLoggingServer } from '@kbn/legacy-logging'; +import { DisposableAppender, LogRecord } from '@kbn/logging'; import { LegacyVars } from '../../types'; export interface LegacyAppenderConfig { diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index afe58ddff92aa2..2fca2f35cb0323 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -25,7 +25,7 @@ jest.mock('fs', () => ({ const dynamicProps = { process: { pid: expect.any(Number) } }; -jest.mock('../../../legacy/server/logging/rotate', () => ({ +jest.mock('@kbn/legacy-logging', () => ({ setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), })); diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 654c3f9948a18a..93d7218b11c281 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -39,17 +39,5 @@ export default { '/test/functional/services/remote', '/src/dev/code_coverage/ingest_coverage', ], - collectCoverageFrom: [ - 'src/plugins/**/*.{ts,tsx}', - '!src/plugins/**/{__test__,__snapshots__,__examples__,mocks,tests}/**/*', - '!src/plugins/**/*.d.ts', - '!src/plugins/**/test_helpers/**', - 'packages/kbn-ui-framework/src/components/**/*.js', - '!packages/kbn-ui-framework/src/components/index.js', - '!packages/kbn-ui-framework/src/components/**/*/index.js', - 'packages/kbn-ui-framework/src/services/**/*.js', - '!packages/kbn-ui-framework/src/services/index.js', - '!packages/kbn-ui-framework/src/services/**/*/index.js', - ], testRunner: 'jasmine2', }; diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index d859c7e45fa200..8448d20aa2fc88 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -70,8 +70,11 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/plugins/apm/e2e/**/*', 'x-pack/plugins/maps/server/fonts/**/*', + // packages for the ingest manager's api integration tests could be valid semver which has dashes 'x-pack/test/fleet_api_integration/apis/fixtures/test_packages/**/*', + + '.teamcity/**/*', ]; /** diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 39df3990ff2ff2..a9b5eec45a75bd 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -19,6 +19,7 @@ import Joi from 'joi'; import os from 'os'; +import { legacyLoggingConfigSchema } from '@kbn/legacy-logging'; const HANDLED_IN_NEW_PLATFORM = Joi.any().description( 'This key is handled in the new platform ONLY' @@ -77,51 +78,7 @@ export default () => uiSettings: HANDLED_IN_NEW_PLATFORM, - logging: Joi.object() - .keys({ - appenders: HANDLED_IN_NEW_PLATFORM, - loggers: HANDLED_IN_NEW_PLATFORM, - root: HANDLED_IN_NEW_PLATFORM, - - silent: Joi.boolean().default(false), - - quiet: Joi.boolean().when('silent', { - is: true, - then: Joi.default(true).valid(true), - otherwise: Joi.default(false), - }), - - verbose: Joi.boolean().when('quiet', { - is: true, - then: Joi.valid(false).default(false), - otherwise: Joi.default(false), - }), - events: Joi.any().default({}), - dest: Joi.string().default('stdout'), - filter: Joi.any().default({}), - json: Joi.boolean().when('dest', { - is: 'stdout', - then: Joi.default(!process.stdout.isTTY), - otherwise: Joi.default(true), - }), - timezone: Joi.string(), - rotate: Joi.object() - .keys({ - enabled: Joi.boolean().default(false), - everyBytes: Joi.number() - // > 1MB - .greater(1048576) - // < 1GB - .less(1073741825) - // 10MB - .default(10485760), - keepFiles: Joi.number().greater(2).less(1024).default(7), - pollingInterval: Joi.number().greater(5000).less(3600000).default(10000), - usePolling: Joi.boolean().default(false), - }) - .default(), - }) - .default(), + logging: legacyLoggingConfigSchema, ops: Joi.object({ interval: Joi.number().default(5000), diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index 013da35d2acb7e..b61a86326ca1a5 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -18,12 +18,12 @@ */ import { constant, once, compact, flatten } from 'lodash'; +import { reconfigureLogging } from '@kbn/legacy-logging'; import { isWorker } from 'cluster'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot, pkg } from '../../core/server/utils'; import { Config } from './config'; -import loggingConfiguration from './logging/configuration'; import httpMixin from './http'; import { coreMixin } from './core'; import { loggingMixin } from './logging'; @@ -154,13 +154,17 @@ export default class KbnServer { applyLoggingConfiguration(settings) { const config = Config.withDefaultSchema(settings); - const loggingOptions = loggingConfiguration(config); + + const loggingConfig = config.get('logging'); + const opsConfig = config.get('ops'); + const subset = { - ops: config.get('ops'), - logging: config.get('logging'), + ops: opsConfig, + logging: loggingConfig, }; const plain = JSON.stringify(subset, null, 2); this.server.log(['info', 'config'], 'New logging configuration:\n' + plain); - this.server.plugins['@elastic/good'].reconfigure(loggingOptions); + + reconfigureLogging(this.server, loggingConfig, opsConfig.interval); } } diff --git a/src/legacy/server/logging/index.js b/src/legacy/server/logging/index.js index 5182de0b7f6130..cb252ba37dc4ef 100644 --- a/src/legacy/server/logging/index.js +++ b/src/legacy/server/logging/index.js @@ -17,21 +17,16 @@ * under the License. */ -import good from '@elastic/good'; -import loggingConfiguration from './configuration'; -import { logWithMetadata } from './log_with_metadata'; -import { setupLoggingRotate } from './rotate'; +import { setupLogging, setupLoggingRotate, attachMetaData } from '@kbn/legacy-logging'; -export async function setupLogging(server, config) { - return await server.register({ - plugin: good, - options: loggingConfiguration(config), +export async function loggingMixin(kbnServer, server, config) { + server.decorate('server', 'logWithMetadata', (tags, message, metadata = {}) => { + server.log(tags, attachMetaData(message, metadata)); }); -} -export async function loggingMixin(kbnServer, server, config) { - logWithMetadata.decorateServer(server); + const loggingConfig = config.get('logging'); + const opsInterval = config.get('ops.interval'); - await setupLogging(server, config); - await setupLoggingRotate(server, config); + await setupLogging(server, loggingConfig, opsInterval); + await setupLoggingRotate(server, loggingConfig); } diff --git a/src/plugins/charts/server/plugin.ts b/src/plugins/charts/server/plugin.ts index 0123459bd25d2e..2a9b82afefc984 100644 --- a/src/plugins/charts/server/plugin.ts +++ b/src/plugins/charts/server/plugin.ts @@ -41,8 +41,18 @@ export class ChartsServerPlugin implements Plugin { }), type: 'json', description: i18n.translate('charts.advancedSettings.visualization.colorMappingText', { - defaultMessage: 'Maps values to specified colors within visualizations', + defaultMessage: + 'Maps values to specific colors in Visualize charts and TSVB. This setting does not apply to Lens.', }), + deprecation: { + message: i18n.translate( + 'charts.advancedSettings.visualization.colorMappingTextDeprecation', + { + defaultMessage: 'This setting is deprecated and will not be supported as of 8.0.', + } + ), + docLinksKey: 'visualizationSettings', + }, category: ['visualization'], schema: schema.string(), }, diff --git a/src/plugins/vis_type_timeseries/common/metric_types.js b/src/plugins/vis_type_timeseries/common/metric_types.ts similarity index 63% rename from src/plugins/vis_type_timeseries/common/metric_types.js rename to src/plugins/vis_type_timeseries/common/metric_types.ts index 05836a6df410a5..a045dbf38c1f9f 100644 --- a/src/plugins/vis_type_timeseries/common/metric_types.js +++ b/src/plugins/vis_type_timeseries/common/metric_types.ts @@ -17,20 +17,26 @@ * under the License. */ -export const METRIC_TYPES = { - PERCENTILE: 'percentile', - PERCENTILE_RANK: 'percentile_rank', - TOP_HIT: 'top_hit', - COUNT: 'count', - DERIVATIVE: 'derivative', - STD_DEVIATION: 'std_deviation', - VARIANCE: 'variance', - SUM_OF_SQUARES: 'sum_of_squares', - CARDINALITY: 'cardinality', - VALUE_COUNT: 'value_count', - AVERAGE: 'avg', - SUM: 'sum', -}; +// We should probably use METRIC_TYPES from data plugin in future. +export enum METRIC_TYPES { + PERCENTILE = 'percentile', + PERCENTILE_RANK = 'percentile_rank', + TOP_HIT = 'top_hit', + COUNT = 'count', + DERIVATIVE = 'derivative', + STD_DEVIATION = 'std_deviation', + VARIANCE = 'variance', + SUM_OF_SQUARES = 'sum_of_squares', + CARDINALITY = 'cardinality', + VALUE_COUNT = 'value_count', + AVERAGE = 'avg', + SUM = 'sum', +} + +// We should probably use BUCKET_TYPES from data plugin in future. +export enum BUCKET_TYPES { + TERMS = 'terms', +} export const EXTENDED_STATS_TYPES = [ METRIC_TYPES.STD_DEVIATION, diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js index bb3f0041abca7f..b2ea90d6a87fe1 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js @@ -45,7 +45,10 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; +import { VisDataContext } from './../../contexts/vis_data_context'; +import { BUCKET_TYPES } from '../../../../common/metric_types'; export class TablePanelConfig extends Component { + static contextType = VisDataContext; constructor(props) { super(props); this.state = { selectedTab: 'data' }; @@ -120,6 +123,8 @@ export class TablePanelConfig extends Component { value={model.pivot_id} indexPattern={model.index_pattern} onChange={this.handlePivotChange} + uiRestrictions={this.context.uiRestrictions} + type={BUCKET_TYPES.TERMS} fullWidth /> diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index b3b7fd32eae19f..8f03b1d7602582 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -191,6 +191,18 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { return await driver.get(url); } + /** + * Retrieves the cookie with the given name. Returns null if there is no such cookie. The cookie will be returned as + * a JSON object as described by the WebDriver wire protocol. + * https://www.selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_Options.html + * + * @param {string} cookieName + * @return {Promise} + */ + public async getCookie(cookieName: string) { + return await driver.manage().getCookie(cookieName); + } + /** * Pauses the execution in the browser, similar to setting a breakpoint for debugging. * @return {Promise} diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index a9d1e28182b291..f1c9df3b25fed0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -14,7 +14,15 @@ import { actionsConfigMock } from '../actions_config.mock'; import { licenseStateMock } from '../lib/license_state.mock'; import { licensingMock } from '../../../licensing/server/mocks'; -const ACTION_TYPE_IDS = ['.index', '.email', '.pagerduty', '.server-log', '.slack', '.webhook']; +const ACTION_TYPE_IDS = [ + '.index', + '.email', + '.pagerduty', + '.server-log', + '.slack', + '.teams', + '.webhook', +]; export function createActionTypeRegistry(): { logger: jest.Mocked; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 3591e05fb3acfb..edbf13d9e5ed1b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -17,6 +17,7 @@ import { getActionType as getWebhookActionType } from './webhook'; import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; import { getActionType as getResilientActionType } from './resilient'; +import { getActionType as getTeamsActionType } from './teams'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -36,4 +37,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts new file mode 100644 index 00000000000000..ffa7c778c0489a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from '../../../../../src/core/server'; +import { Services } from '../types'; +import { validateParams, validateSecrets } from '../lib'; +import axios from 'axios'; +import { ActionParamsType, ActionTypeSecretsType, getActionType, TeamsActionType } from './teams'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { createActionTypeRegistry } from './index.test'; +import * as utils from './lib/axios_utils'; + +jest.mock('axios'); +jest.mock('./lib/axios_utils', () => { + const originalUtils = jest.requireActual('./lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; + +const ACTION_TYPE_ID = '.teams'; + +const services: Services = actionsMock.createServices(); + +let actionType: TeamsActionType; +let mockedLogger: jest.Mocked; + +beforeAll(() => { + const { logger, actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get<{}, ActionTypeSecretsType, ActionParamsType>(ACTION_TYPE_ID); + mockedLogger = logger; +}); + +describe('action registration', () => { + test('returns action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('Microsoft Teams'); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + expect(validateParams(actionType, { message: 'a message' })).toEqual({ + message: 'a message', + }); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [message]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateParams(actionType, { message: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [message]: expected value of type [string] but got [number]"` + ); + }); +}); + +describe('validateActionTypeSecrets()', () => { + test('should validate and pass when config is valid', () => { + validateSecrets(actionType, { + webhookUrl: 'https://example.com', + }); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateSecrets(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [number]"` + ); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 'fee-fi-fo-fum' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring teams action: unable to parse host name from webhookUrl"` + ); + }); + + test('should validate and pass when the teams webhookUrl is added to allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureUriAllowed: (url) => { + expect(url).toEqual('https://outlook.office.com/'); + }, + }, + }); + + expect(validateSecrets(actionType, { webhookUrl: 'https://outlook.office.com/' })).toEqual({ + webhookUrl: 'https://outlook.office.com/', + }); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureHostnameAllowed: () => { + throw new Error(`target hostname is not added to allowedHosts`); + }, + }, + }); + + expect(() => { + validateSecrets(actionType, { webhookUrl: 'https://outlook.office.com/' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring teams action: target hostname is not added to allowedHosts"` + ); + }); +}); + +describe('execute()', () => { + beforeAll(() => { + requestMock.mockReset(); + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: actionsConfigMock.create(), + }); + }); + + beforeEach(() => { + requestMock.mockReset(); + requestMock.mockResolvedValue({ + status: 200, + statusText: '', + data: '', + headers: [], + config: {}, + }); + }); + + test('calls the mock executor with success', async () => { + const response = await actionType.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "axios": undefined, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "proxySettings": undefined, + "url": "http://example.com", + } + `); + expect(response).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "data": Object { + "text": "this invocation should succeed", + }, + "status": "ok", + } + `); + }); + + test('calls the mock executor with success proxy', async () => { + const response = await actionType.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + proxySettings: { + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }, + }); + expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "axios": undefined, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "proxySettings": Object { + "proxyRejectUnauthorizedCertificates": false, + "proxyUrl": "https://someproxyhost", + }, + "url": "http://example.com", + } + `); + expect(response).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "data": Object { + "text": "this invocation should succeed", + }, + "status": "ok", + } + `); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.ts new file mode 100644 index 00000000000000..e152a65217ce2f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { URL } from 'url'; +import { curry, isString } from 'lodash'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { map, getOrElse } from 'fp-ts/lib/Option'; +import { Logger } from '../../../../../src/core/server'; +import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header'; +import { isOk, promiseResult, Result } from './lib/result_type'; +import { request } from './lib/axios_utils'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +export type TeamsActionType = ActionType<{}, ActionTypeSecretsType, ActionParamsType, unknown>; +export type TeamsActionTypeExecutorOptions = ActionTypeExecutorOptions< + {}, + ActionTypeSecretsType, + ActionParamsType +>; + +// secrets definition + +export type ActionTypeSecretsType = TypeOf; + +const secretsSchemaProps = { + webhookUrl: schema.string(), +}; +const SecretsSchema = schema.object(secretsSchemaProps); + +// params definition + +export type ActionParamsType = TypeOf; + +const ParamsSchema = schema.object({ + message: schema.string({ minLength: 1 }), +}); + +// action type definition +export function getActionType({ + logger, + configurationUtilities, +}: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): TeamsActionType { + return { + id: '.teams', + minimumLicenseRequired: 'gold', + name: i18n.translate('xpack.actions.builtin.teamsTitle', { + defaultMessage: 'Microsoft Teams', + }), + validate: { + secrets: schema.object(secretsSchemaProps, { + validate: curry(validateActionTypeConfig)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor: curry(teamsExecutor)({ logger }), + }; +} + +function validateActionTypeConfig( + configurationUtilities: ActionsConfigurationUtilities, + secretsObject: ActionTypeSecretsType +) { + let url: URL; + try { + url = new URL(secretsObject.webhookUrl); + } catch (err) { + return i18n.translate('xpack.actions.builtin.teams.teamsConfigurationErrorNoHostname', { + defaultMessage: 'error configuring teams action: unable to parse host name from webhookUrl', + }); + } + + try { + configurationUtilities.ensureHostnameAllowed(url.hostname); + } catch (allowListError) { + return i18n.translate('xpack.actions.builtin.teams.teamsConfigurationError', { + defaultMessage: 'error configuring teams action: {message}', + values: { + message: allowListError.message, + }, + }); + } +} + +// action executor + +async function teamsExecutor( + { logger }: { logger: Logger }, + execOptions: TeamsActionTypeExecutorOptions +): Promise> { + const actionId = execOptions.actionId; + const secrets = execOptions.secrets; + const params = execOptions.params; + const { webhookUrl } = secrets; + const { message } = params; + const data = { text: message }; + + const axiosInstance = axios.create(); + + const result: Result = await promiseResult( + request({ + axios: axiosInstance, + method: 'post', + url: webhookUrl, + logger, + data, + proxySettings: execOptions.proxySettings, + }) + ); + + if (isOk(result)) { + const { + value: { status, statusText, data: responseData, headers: responseHeaders }, + } = result; + + // Microsoft Teams connectors do not throw 429s. Rather they will return a 200 response + // with a 429 message in the response body when the rate limit is hit + // https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#rate-limiting-for-connectors + if (isString(responseData) && responseData.includes('ErrorCode:ApplicationThrottled')) { + return pipe( + getRetryAfterIntervalFromHeaders(responseHeaders), + map((retry) => retryResultSeconds(actionId, message, retry)), + getOrElse(() => retryResult(actionId, message)) + ); + } + + logger.debug(`response from teams action "${actionId}": [HTTP ${status}] ${statusText}`); + + return successResult(actionId, data); + } else { + const { error } = result; + + if (error.response) { + const { status, statusText } = error.response; + const serviceMessage = `[${status}] ${statusText}`; + logger.error(`error on ${actionId} Microsoft Teams event: ${serviceMessage}`); + + // special handling for 5xx + if (status >= 500) { + return retryResult(actionId, serviceMessage); + } + + return errorResultInvalid(actionId, serviceMessage); + } + + logger.debug(`error on ${actionId} Microsoft Teams action: unexpected error`); + return errorResultUnexpectedError(actionId); + } +} + +function successResult(actionId: string, data: unknown): ActionTypeExecutorResult { + return { status: 'ok', data, actionId }; +} + +function errorResultUnexpectedError(actionId: string): ActionTypeExecutorResult { + const errMessage = i18n.translate('xpack.actions.builtin.teams.unreachableErrorMessage', { + defaultMessage: 'error posting to Microsoft Teams, unexpected error', + }); + return { + status: 'error', + message: errMessage, + actionId, + }; +} + +function errorResultInvalid( + actionId: string, + serviceMessage: string +): ActionTypeExecutorResult { + const errMessage = i18n.translate('xpack.actions.builtin.teams.invalidResponseErrorMessage', { + defaultMessage: 'error posting to Microsoft Teams, invalid response', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + }; +} + +function retryResult(actionId: string, message: string): ActionTypeExecutorResult { + const errMessage = i18n.translate( + 'xpack.actions.builtin.teams.errorPostingRetryLaterErrorMessage', + { + defaultMessage: 'error posting a Microsoft Teams message, retry later', + } + ); + return { + status: 'error', + message: errMessage, + retry: true, + actionId, + }; +} + +function retryResultSeconds( + actionId: string, + message: string, + retryAfter: number +): ActionTypeExecutorResult { + const retryEpoch = Date.now() + retryAfter * 1000; + const retry = new Date(retryEpoch); + const retryString = retry.toISOString(); + const errMessage = i18n.translate( + 'xpack.actions.builtin.teams.errorPostingRetryDateErrorMessage', + { + defaultMessage: 'error posting a Microsoft Teams message, retry at {retryString}', + values: { + retryString, + }, + } + ); + return { + status: 'error', + message: errMessage, + retry, + actionId, + serviceMessage: message, + }; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index a160735e89a935..e61936321b8e0a 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { Observable } from 'rxjs'; import { PluginInitializerContext, Plugin, @@ -13,7 +14,6 @@ import { CoreStart, KibanaRequest, Logger, - SharedGlobalConfig, RequestHandler, IContextProvider, ElasticsearchServiceStart, @@ -128,7 +128,6 @@ const includedHiddenTypes = [ ]; export class ActionsPlugin implements Plugin, PluginStartContract> { - private readonly kibanaIndex: Promise; private readonly config: Promise; private readonly logger: Logger; @@ -143,20 +142,14 @@ export class ActionsPlugin implements Plugin, Plugi private isESOUsingEphemeralEncryptionKey?: boolean; private readonly telemetryLogger: Logger; private readonly preconfiguredActions: PreConfiguredAction[]; + private readonly kibanaIndexConfig: Observable<{ kibana: { index: string } }>; constructor(initContext: PluginInitializerContext) { this.config = initContext.config.create().pipe(first()).toPromise(); - - this.kibanaIndex = initContext.config.legacy.globalConfig$ - .pipe( - first(), - map((config: SharedGlobalConfig) => config.kibana.index) - ) - .toPromise(); - this.logger = initContext.logger.get('actions'); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; + this.kibanaIndexConfig = initContext.config.legacy.globalConfig$; } public async setup( @@ -220,22 +213,26 @@ export class ActionsPlugin implements Plugin, Plugi const usageCollection = plugins.usageCollection; if (usageCollection) { - initializeActionsTelemetry( - this.telemetryLogger, - plugins.taskManager, - core, - await this.kibanaIndex + registerActionsUsageCollector( + usageCollection, + core.getStartServices().then(([_, { taskManager }]) => taskManager) ); - - core.getStartServices().then(async ([, startPlugins]) => { - registerActionsUsageCollector(usageCollection, startPlugins.taskManager); - }); } - core.http.registerRouteHandlerContext( - 'actions', - this.createRouteHandlerContext(core, await this.kibanaIndex) - ); + this.kibanaIndexConfig.subscribe((config) => { + core.http.registerRouteHandlerContext( + 'actions', + this.createRouteHandlerContext(core, config.kibana.index) + ); + if (usageCollection) { + initializeActionsTelemetry( + this.telemetryLogger, + plugins.taskManager, + core, + config.kibana.index + ); + } + }); // Routes const router = core.http.createRouter(); @@ -269,7 +266,7 @@ export class ActionsPlugin implements Plugin, Plugi actionExecutor, actionTypeRegistry, taskRunnerFactory, - kibanaIndex, + kibanaIndexConfig, isESOUsingEphemeralEncryptionKey, preconfiguredActions, instantiateAuthorization, @@ -297,10 +294,12 @@ export class ActionsPlugin implements Plugin, Plugi request ); + const kibanaIndex = (await kibanaIndexConfig.pipe(first()).toPromise()).kibana.index; + return new ActionsClient({ unsecuredSavedObjectsClient, actionTypeRegistry: actionTypeRegistry!, - defaultKibanaIndex: await kibanaIndex, + defaultKibanaIndex: kibanaIndex, scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), preconfiguredActions, request, diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts index 0e6c2ff37eb029..39a61cebe92dcd 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts @@ -24,7 +24,7 @@ describe('registerActionsUsageCollector', () => { it('should call registerCollector', () => { registerActionsUsageCollector( usageCollectionMock as UsageCollectionSetup, - mockTaskManagerStart + new Promise(() => mockTaskManagerStart) ); expect(usageCollectionMock.registerCollector).toHaveBeenCalledTimes(1); }); @@ -32,7 +32,7 @@ describe('registerActionsUsageCollector', () => { it('should call makeUsageCollector with type = actions', () => { registerActionsUsageCollector( usageCollectionMock as UsageCollectionSetup, - mockTaskManagerStart + new Promise(() => mockTaskManagerStart) ); expect(usageCollectionMock.makeUsageCollector).toHaveBeenCalledTimes(1); expect(usageCollectionMock.makeUsageCollector.mock.calls[0][0].type).toBe('actions'); diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index fac57b6282c445..f86c6a40e05055 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -26,11 +26,14 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { export function createActionsUsageCollector( usageCollection: UsageCollectionSetup, - taskManager: TaskManagerStartContract + taskManager: Promise ) { return usageCollection.makeUsageCollector({ type: 'actions', - isReady: () => true, + isReady: async () => { + await taskManager; + return true; + }, schema: { count_total: { type: 'long' }, count_active_total: { type: 'long' }, @@ -79,7 +82,7 @@ async function getLatestTaskState(taskManager: TaskManagerStartContract) { export function registerActionsUsageCollector( usageCollection: UsageCollectionSetup, - taskManager: TaskManagerStartContract + taskManager: Promise ) { const collector = createActionsUsageCollector(usageCollection, taskManager); usageCollection.registerCollector(collector); diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 99cb45130718ab..4bfb44425544a9 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -5,6 +5,7 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; import { first, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { combineLatest } from 'rxjs'; import { SecurityPluginSetup } from '../../security/server'; @@ -28,7 +29,6 @@ import { SavedObjectsServiceStart, IContextProvider, RequestHandler, - SharedGlobalConfig, ElasticsearchServiceStart, ILegacyClusterClient, StatusServiceSetup, @@ -124,10 +124,10 @@ export class AlertingPlugin { private security?: SecurityPluginSetup; private readonly alertsClientFactory: AlertsClientFactory; private readonly telemetryLogger: Logger; - private readonly kibanaIndex: Promise; private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private eventLogService?: IEventLogService; private eventLogger?: IEventLogger; + private readonly kibanaIndexConfig: Observable<{ kibana: { index: string } }>; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create().pipe(first()).toPromise(); @@ -135,19 +135,14 @@ export class AlertingPlugin { this.taskRunnerFactory = new TaskRunnerFactory(); this.alertsClientFactory = new AlertsClientFactory(); this.telemetryLogger = initializerContext.logger.get('usage'); - this.kibanaIndex = initializerContext.config.legacy.globalConfig$ - .pipe( - first(), - map((config: SharedGlobalConfig) => config.kibana.index) - ) - .toPromise(); + this.kibanaIndexConfig = initializerContext.config.legacy.globalConfig$; this.kibanaVersion = initializerContext.env.packageInfo.version; } - public async setup( + public setup( core: CoreSetup, plugins: AlertingPluginsSetup - ): Promise { + ): PluginSetupContract { this.licenseState = new LicenseState(plugins.licensing.license$); this.security = plugins.security; @@ -187,15 +182,17 @@ export class AlertingPlugin { const usageCollection = plugins.usageCollection; if (usageCollection) { - initializeAlertingTelemetry( - this.telemetryLogger, - core, - plugins.taskManager, - await this.kibanaIndex + registerAlertsUsageCollector( + usageCollection, + core.getStartServices().then(([_, { taskManager }]) => taskManager) ); - - core.getStartServices().then(async ([, startPlugins]) => { - registerAlertsUsageCollector(usageCollection, startPlugins.taskManager); + this.kibanaIndexConfig.subscribe((config) => { + initializeAlertingTelemetry( + this.telemetryLogger, + core, + plugins.taskManager, + config.kibana.index + ); }); } diff --git a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts index a5f83bc393d4ec..e731e3f536261f 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts @@ -22,12 +22,18 @@ describe('registerAlertsUsageCollector', () => { }); it('should call registerCollector', () => { - registerAlertsUsageCollector(usageCollectionMock as UsageCollectionSetup, taskManagerStart); + registerAlertsUsageCollector( + usageCollectionMock as UsageCollectionSetup, + new Promise(() => taskManagerStart) + ); expect(usageCollectionMock.registerCollector).toHaveBeenCalledTimes(1); }); it('should call makeUsageCollector with type = alerts', () => { - registerAlertsUsageCollector(usageCollectionMock as UsageCollectionSetup, taskManagerStart); + registerAlertsUsageCollector( + usageCollectionMock as UsageCollectionSetup, + new Promise(() => taskManagerStart) + ); expect(usageCollectionMock.makeUsageCollector).toHaveBeenCalledTimes(1); expect(usageCollectionMock.makeUsageCollector.mock.calls[0][0].type).toBe('alerts'); }); diff --git a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts index de82dd31877afb..40a9983ae27861 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts @@ -44,11 +44,14 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { export function createAlertsUsageCollector( usageCollection: UsageCollectionSetup, - taskManager: TaskManagerStartContract + taskManager: Promise ) { return usageCollection.makeUsageCollector({ type: 'alerts', - isReady: () => true, + isReady: async () => { + await taskManager; + return true; + }, fetch: async () => { try { const doc = await getLatestTaskState(await taskManager); @@ -129,7 +132,7 @@ async function getLatestTaskState(taskManager: TaskManagerStartContract) { export function registerAlertsUsageCollector( usageCollection: UsageCollectionSetup, - taskManager: TaskManagerStartContract + taskManager: Promise ) { const collector = createAlertsUsageCollector(usageCollection, taskManager); usageCollection.registerCollector(collector); diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index ffd3a39e8afd1a..849dd7f5c3e2df 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -29,7 +29,7 @@ module.exports = { roots: [`${rootDir}/common`, `${rootDir}/public`, `${rootDir}/server`], collectCoverage: true, collectCoverageFrom: [ - ...(jestConfig.collectCoverageFrom ?? []), + ...(jestConfig.collectCoverageFrom || []), '**/*.{js,mjs,jsx,ts,tsx}', '!**/*.stories.{js,mjs,ts,tsx}', '!**/dev_docs/**', diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index dfc3d6b4b9ec8d..7fcbe7c518cd0b 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -10,7 +10,6 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router } from 'react-router-dom'; -import 'react-vis/dist/style.css'; import styled, { DefaultTheme, ThemeProvider } from 'styled-components'; import { KibanaContextProvider, diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index f96dc14e342645..63fb69d6d7cbfb 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -13,8 +13,8 @@ import { APMRouteDefinition } from '../../../../application/routes'; import { toQuery } from '../../../shared/Links/url_helpers'; import { ErrorGroupDetails } from '../../ErrorGroupDetails'; import { Home } from '../../Home'; -import { ServiceDetails } from '../../ServiceDetails'; -import { ServiceNodeMetrics } from '../../ServiceNodeMetrics'; +import { ServiceDetails } from '../../service_details'; +import { ServiceNodeMetrics } from '../../service_node_metrics'; import { Settings } from '../../Settings'; import { AgentConfigurations } from '../../Settings/AgentConfigurations'; import { AnomalyDetection } from '../../Settings/anomaly_detection'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts index 4610205cee7ed0..7ce9d3f25354cc 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts @@ -8,6 +8,7 @@ import { FetchDataParams, HasDataParams, UxFetchDataResponse, + UXHasDataResponse, } from '../../../../../observability/public/'; import { callApmApi } from '../../../services/rest/createCallApmApi'; @@ -35,7 +36,9 @@ export const fetchUxOverviewDate = async ({ }; }; -export async function hasRumData({ absoluteTime }: HasDataParams) { +export async function hasRumData({ + absoluteTime, +}: HasDataParams): Promise { return await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', params: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/Documentation.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/Documentation.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/FiltersSection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/FiltersSection.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/FlyoutFooter.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/FlyoutFooter.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkSection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkSection.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper.test.ts similarity index 99% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper.test.ts index 5f8e0b9052a656..4af9321152da35 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper.test.ts @@ -6,7 +6,7 @@ import { getSelectOptions, replaceTemplateVariables, -} from '../CustomLinkFlyout/helper'; +} from '../CreateEditCustomLinkFlyout/helper'; import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; describe('Custom link helper', () => { diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper.ts diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/index.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/index.tsx index 9687846d6c5205..c6566af3a8b61b 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/index.tsx @@ -37,7 +37,7 @@ interface Props { const filtersEmptyState: Filter[] = [{ key: '', value: '' }]; -export function CustomLinkFlyout({ +export function CreateEditCustomLinkFlyout({ onClose, onSave, onDelete, diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx index 3a2aa01ba3bc48..7fa8e3a025956d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; +import { LinkPreview } from '../CreateEditCustomLinkFlyout/LinkPreview'; import { render, getNodeText, diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx deleted file mode 100644 index 2017aa42e1c5a6..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -export function Title() { - return ( - - - - - -

- {i18n.translate('xpack.apm.settings.customizeUI.customLink', { - defaultMessage: 'Custom Links', - })} -

-
-
-
-
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index a7feafad11111a..96a634828f6696 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -21,7 +21,7 @@ import { expectTextsInDocument, expectTextsNotInDocument, } from '../../../../../utils/testHelpers'; -import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import * as saveCustomLink from './CreateEditCustomLinkFlyout/saveCustomLink'; import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const data = [ diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index d872f6d21ed96d..771a8c6154dc04 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -9,6 +9,7 @@ import { EuiFlexItem, EuiPanel, EuiSpacer, + EuiTitle, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -20,10 +21,9 @@ import { FETCH_STATUS, useFetcher } from '../../../../../hooks/useFetcher'; import { useLicense } from '../../../../../hooks/useLicense'; import { LicensePrompt } from '../../../../shared/LicensePrompt'; import { CreateCustomLinkButton } from './CreateCustomLinkButton'; -import { CustomLinkFlyout } from './CustomLinkFlyout'; +import { CreateEditCustomLinkFlyout } from './CreateEditCustomLinkFlyout'; import { CustomLinkTable } from './CustomLinkTable'; import { EmptyPrompt } from './EmptyPrompt'; -import { Title } from './Title'; export function CustomLinkOverview() { const license = useLicense(); @@ -35,9 +35,14 @@ export function CustomLinkOverview() { >(); const { data: customLinks = [], status, refetch } = useFetcher( - (callApmApi) => - callApmApi({ endpoint: 'GET /api/apm/settings/custom_links' }), - [] + async (callApmApi) => { + if (hasValidLicense) { + return callApmApi({ + endpoint: 'GET /api/apm/settings/custom_links', + }); + } + }, + [hasValidLicense] ); useEffect(() => { @@ -61,7 +66,7 @@ export function CustomLinkOverview() { return ( <> {isFlyoutOpen && ( - - + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle> + <EuiFlexGroup + alignItems="center" + gutterSize="s" + responsive={false} + > + <EuiFlexItem grow={false}> + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink', + { + defaultMessage: 'Custom Links', + } + )} + </h2> + </EuiFlexItem> + </EuiFlexGroup> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> {hasValidLicense && !showEmptyPrompt && ( <EuiFlexItem> diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index cc6bacc4f3ccb8..8a99773a97baf6 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -21,11 +21,11 @@ import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; import { useWaterfall } from '../../../hooks/useWaterfall'; import { ApmHeader } from '../../shared/ApmHeader'; -import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { TransactionDistribution } from './Distribution'; import { WaterfallWithSummmary } from './WaterfallWithSummmary'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { LegacyChartsSyncContextProvider as ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 8208916c203377..ff4863e9b8420c 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -29,7 +29,7 @@ import { useServiceTransactionTypes } from '../../../hooks/useServiceTransaction import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/index.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx rename to x-pack/plugins/apm/public/components/app/service_details/index.tsx index 8df2b0fda7a7e1..70acc2038e1a77 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/index.tsx @@ -8,7 +8,7 @@ import { EuiTitle } from '@elastic/eui'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { ApmHeader } from '../../shared/ApmHeader'; -import { ServiceDetailTabs } from './ServiceDetailTabs'; +import { ServiceDetailTabs } from './service_detail_tabs'; interface Props extends RouteComponentProps<{ serviceName: string }> { tab: React.ComponentProps<typeof ServiceDetailTabs>['tab']; diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx rename to x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index f42b94b8afe335..22c5a2b101ddcb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -20,7 +20,7 @@ import { useTransactionOverviewHref } from '../../shared/Links/apm/TransactionOv import { MainTabs } from '../../shared/main_tabs'; import { ErrorGroupOverview } from '../ErrorGroupOverview'; import { ServiceMap } from '../ServiceMap'; -import { ServiceMetrics } from '../ServiceMetrics'; +import { ServiceMetrics } from '../service_metrics'; import { ServiceNodeOverview } from '../ServiceNodeOverview'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../TransactionOverview'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx similarity index 81% rename from x-pack/plugins/apm/public/components/app/ServiceMetrics/index.tsx rename to x-pack/plugins/apm/public/components/app/service_metrics/index.tsx index 5808c54d578c6d..ded2698c5455d2 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx @@ -14,9 +14,9 @@ import { } from '@elastic/eui'; import React, { useMemo } from 'react'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; -import { MetricsChart } from '../../shared/charts/MetricsChart'; +import { MetricsChart } from '../../shared/charts/metrics_chart'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { LegacyChartsSyncContextProvider as ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; import { Projection } from '../../../../common/projections'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; @@ -31,7 +31,7 @@ export function ServiceMetrics({ serviceName, }: ServiceMetricsProps) { const { urlParams } = useUrlParams(); - const { data } = useServiceMetricCharts(urlParams, agentName); + const { data, status } = useServiceMetricCharts(urlParams, agentName); const { start, end } = urlParams; const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( @@ -60,7 +60,12 @@ export function ServiceMetrics({ {data.charts.map((chart) => ( <EuiFlexItem key={chart.key}> <EuiPanel> - <MetricsChart start={start} end={end} chart={chart} /> + <MetricsChart + start={start} + end={end} + chart={chart} + fetchStatus={status} + /> </EuiPanel> </EuiFlexItem> ))} diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx rename to x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx rename to x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index efa6110fea1008..dd703d445cc608 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -22,14 +22,14 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import styled from 'styled-components'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; -import { LegacyChartsSyncContextProvider as ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; import { useAgentName } from '../../../hooks/useAgentName'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { px, truncate, unit } from '../../../style/variables'; import { ApmHeader } from '../../shared/ApmHeader'; -import { MetricsChart } from '../../shared/charts/MetricsChart'; +import { MetricsChart } from '../../shared/charts/metrics_chart'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; const INITIAL_DATA = { @@ -178,7 +178,12 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { {data.charts.map((chart) => ( <EuiFlexItem key={chart.key}> <EuiPanel> - <MetricsChart start={start} end={end} chart={chart} /> + <MetricsChart + start={start} + end={end} + chart={chart} + fetchStatus={status} + /> </EuiPanel> </EuiFlexItem> ))} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx deleted file mode 100644 index 62952d1fb501b7..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { act, fireEvent, render } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; -import { expectTextsInDocument } from '../../../../utils/testHelpers'; -import { CustomLinkPopover } from './CustomLinkPopover'; - -function Wrapper({ children }: { children?: ReactNode }) { - return ( - <MemoryRouter> - <MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper> - </MemoryRouter> - ); -} - -describe('CustomLinkPopover', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'http://elastic.co' }, - { - id: '2', - label: 'bar', - url: 'http://elastic.co?service.name={{service.name}}', - }, - ] as CustomLink[]; - const transaction = ({ - service: { name: 'foo.bar' }, - } as unknown) as Transaction; - it('renders popover', () => { - const component = render( - <CustomLinkPopover - customLinks={customLinks} - transaction={transaction} - onCreateCustomLinkClick={jest.fn()} - onClose={jest.fn()} - />, - { wrapper: Wrapper } - ); - expectTextsInDocument(component, ['CUSTOM LINKS', 'Create', 'foo', 'bar']); - }); - - it('closes popover', () => { - const handleCloseMock = jest.fn(); - const { getByText } = render( - <CustomLinkPopover - customLinks={customLinks} - transaction={transaction} - onCreateCustomLinkClick={jest.fn()} - onClose={handleCloseMock} - />, - { wrapper: Wrapper } - ); - expect(handleCloseMock).not.toHaveBeenCalled(); - act(() => { - fireEvent.click(getByText('CUSTOM LINKS')); - }); - expect(handleCloseMock).toHaveBeenCalled(); - }); - - it('opens flyout to create new custom link', () => { - const handleCreateCustomLinkClickMock = jest.fn(); - const { getByText } = render( - <CustomLinkPopover - customLinks={customLinks} - transaction={transaction} - onCreateCustomLinkClick={handleCreateCustomLinkClickMock} - onClose={jest.fn()} - />, - { wrapper: Wrapper } - ); - expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); - act(() => { - fireEvent.click(getByText('Create')); - }); - expect(handleCreateCustomLinkClickMock).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx deleted file mode 100644 index 27c6aa82ac674b..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { - EuiPopoverTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { CustomLinkSection } from './CustomLinkSection'; -import { ManageCustomLink } from './ManageCustomLink'; -import { px } from '../../../../style/variables'; - -const ScrollableContainer = styled.div` - -ms-overflow-style: none; - max-height: ${px(535)}; - overflow: scroll; -`; - -export function CustomLinkPopover({ - customLinks, - onCreateCustomLinkClick, - onClose, - transaction, -}: { - customLinks: CustomLink[]; - onCreateCustomLinkClick: () => void; - onClose: () => void; - transaction: Transaction; -}) { - return ( - <> - <EuiPopoverTitle> - <EuiFlexGroup> - <EuiFlexItem style={{ alignItems: 'flex-start' }}> - <EuiButtonEmpty - color="text" - size="xs" - onClick={onClose} - iconType="arrowLeft" - style={{ fontWeight: 'bold' }} - flush="left" - > - {i18n.translate( - 'xpack.apm.transactionActionMenu.customLink.popover.title', - { - defaultMessage: 'CUSTOM LINKS', - } - )} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem> - <ManageCustomLink - onCreateCustomLinkClick={onCreateCustomLinkClick} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPopoverTitle> - <ScrollableContainer> - <CustomLinkSection - customLinks={customLinks} - transaction={transaction} - /> - </ScrollableContainer> - </> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx deleted file mode 100644 index 6b421bc3703322..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.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; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiLink, EuiText } from '@elastic/eui'; -import Mustache from 'mustache'; -import React from 'react'; -import styled from 'styled-components'; -import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { px, truncate, units } from '../../../../style/variables'; - -const LinkContainer = styled.li` - margin-top: ${px(units.half)}; - &:first-of-type { - margin-top: 0; - } -`; - -const TruncateText = styled(EuiText)` - font-weight: 500; - line-height: ${px(units.unit)}; - ${truncate(px(units.unit * 25))} -`; - -export function CustomLinkSection({ - customLinks, - transaction, -}: { - customLinks: CustomLink[]; - transaction: Transaction; -}) { - return ( - <ul> - {customLinks.map((link) => { - let href = link.url; - try { - href = Mustache.render(link.url, transaction); - } catch (e) { - // ignores any error that happens - } - return ( - <LinkContainer key={link.id}> - <EuiLink href={href} target="_blank"> - <TruncateText size="s">{link.label}</TruncateText> - </EuiLink> - </LinkContainer> - ); - })} - </ul> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx deleted file mode 100644 index d6484f52e84f98..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.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; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { - EuiText, - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { isEmpty } from 'lodash'; -import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { - ActionMenuDivider, - SectionSubtitle, -} from '../../../../../../observability/public'; -import { CustomLinkSection } from './CustomLinkSection'; -import { ManageCustomLink } from './ManageCustomLink'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { LoadingStatePrompt } from '../../LoadingStatePrompt'; -import { px } from '../../../../style/variables'; - -const SeeMoreButton = styled.button<{ show: boolean }>` - display: ${(props) => (props.show ? 'flex' : 'none')}; - align-items: center; - width: 100%; - justify-content: space-between; - &:hover { - text-decoration: underline; - } -`; - -export function CustomLink({ - customLinks, - status, - onCreateCustomLinkClick, - onSeeMoreClick, - transaction, -}: { - customLinks: CustomLinkType[]; - status: FETCH_STATUS; - onCreateCustomLinkClick: () => void; - onSeeMoreClick: () => void; - transaction: Transaction; -}) { - const renderEmptyPrompt = ( - <> - <EuiText size="xs" grow={false} style={{ width: px(300) }}> - {i18n.translate('xpack.apm.customLink.empty', { - defaultMessage: - 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.', - })} - </EuiText> - <EuiSpacer size="s" /> - <EuiButtonEmpty - iconType="plusInCircle" - size="xs" - onClick={onCreateCustomLinkClick} - > - {i18n.translate('xpack.apm.customLink.buttom.create', { - defaultMessage: 'Create custom link', - })} - </EuiButtonEmpty> - </> - ); - - const renderCustomLinkBottomSection = isEmpty(customLinks) ? ( - renderEmptyPrompt - ) : ( - <SeeMoreButton onClick={onSeeMoreClick} show={customLinks.length > 3}> - <EuiText size="s"> - {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', { - defaultMessage: 'See more', - })} - </EuiText> - <EuiIcon type="arrowRight" /> - </SeeMoreButton> - ); - - return ( - <> - <ActionMenuDivider /> - <EuiFlexGroup> - <EuiFlexItem style={{ justifyContent: 'center' }}> - <EuiText size={'s'} grow={false}> - <h5> - {i18n.translate( - 'xpack.apm.transactionActionMenu.customLink.section', - { - defaultMessage: 'Custom Links', - } - )} - </h5> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <ManageCustomLink - onCreateCustomLinkClick={onCreateCustomLinkClick} - showCreateCustomLinkButton={!!customLinks.length} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="s" /> - <SectionSubtitle> - {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', { - defaultMessage: 'Links will open in a new window.', - })} - </SectionSubtitle> - <CustomLinkSection - customLinks={customLinks.slice(0, 3)} - transaction={transaction} - /> - <EuiSpacer size="s" /> - {status === FETCH_STATUS.LOADING ? ( - <LoadingStatePrompt /> - ) : ( - renderCustomLinkBottomSection - )} - </> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.test.tsx similarity index 82% rename from x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.test.tsx index 88a4137b47200a..16d526bda2103a 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; import { render } from '@testing-library/react'; -import { CustomLinkSection } from './CustomLinkSection'; +import { CustomLinkList } from './CustomLinkList'; import { expectTextsInDocument, expectTextsNotInDocument, @@ -13,7 +13,7 @@ import { import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; -describe('CustomLinkSection', () => { +describe('CustomLinkList', () => { const customLinks = [ { id: '1', label: 'foo', url: 'http://elastic.co' }, { @@ -27,14 +27,14 @@ describe('CustomLinkSection', () => { } as unknown) as Transaction; it('shows links', () => { const component = render( - <CustomLinkSection customLinks={customLinks} transaction={transaction} /> + <CustomLinkList customLinks={customLinks} transaction={transaction} /> ); expectTextsInDocument(component, ['foo', 'bar']); }); it('doesnt show any links', () => { const component = render( - <CustomLinkSection customLinks={[]} transaction={transaction} /> + <CustomLinkList customLinks={[]} transaction={transaction} /> ); expectTextsNotInDocument(component, ['foo', 'bar']); }); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.tsx new file mode 100644 index 00000000000000..0304b850d6ceec --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkList.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Mustache from 'mustache'; +import React from 'react'; +import { + SectionLinks, + SectionLink, +} from '../../../../../../observability/public'; +import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { px, unit } from '../../../../style/variables'; + +export function CustomLinkList({ + customLinks, + transaction, +}: { + customLinks: CustomLink[]; + transaction: Transaction; +}) { + return ( + <SectionLinks style={{ maxHeight: px(unit * 10), overflowY: 'auto' }}> + {customLinks.map((link) => { + const href = getHref(link, transaction); + return ( + <SectionLink + key={link.id} + label={link.label} + href={href} + target="_blank" + /> + ); + })} + </SectionLinks> + ); +} + +function getHref(link: CustomLink, transaction: Transaction) { + try { + return Mustache.render(link.url, transaction); + } catch (e) { + return link.url; + } +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.test.tsx similarity index 78% rename from x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.test.tsx index 29e93a47629b31..0241167aba1fb0 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.test.tsx @@ -12,7 +12,7 @@ import { expectTextsInDocument, expectTextsNotInDocument, } from '../../../../utils/testHelpers'; -import { ManageCustomLink } from './ManageCustomLink'; +import { CustomLinkToolbar } from './CustomLinkToolbar'; function Wrapper({ children }: { children?: ReactNode }) { return ( @@ -22,23 +22,20 @@ function Wrapper({ children }: { children?: ReactNode }) { ); } -describe('ManageCustomLink', () => { +describe('CustomLinkToolbar', () => { it('renders with create button', () => { - const component = render( - <ManageCustomLink onCreateCustomLinkClick={jest.fn()} />, - { wrapper: Wrapper } - ); + const component = render(<CustomLinkToolbar onClickCreate={jest.fn()} />, { + wrapper: Wrapper, + }); expect( component.getByLabelText('Custom links settings page') ).toBeInTheDocument(); expectTextsInDocument(component, ['Create']); }); + it('renders without create button', () => { const component = render( - <ManageCustomLink - onCreateCustomLinkClick={jest.fn()} - showCreateCustomLinkButton={false} - />, + <CustomLinkToolbar onClickCreate={jest.fn()} showCreateButton={false} />, { wrapper: Wrapper } ); expect( @@ -46,12 +43,11 @@ describe('ManageCustomLink', () => { ).toBeInTheDocument(); expectTextsNotInDocument(component, ['Create']); }); + it('opens flyout to create new custom link', () => { const handleCreateCustomLinkClickMock = jest.fn(); const { getByText } = render( - <ManageCustomLink - onCreateCustomLinkClick={handleCreateCustomLinkClickMock} - />, + <CustomLinkToolbar onClickCreate={handleCreateCustomLinkClickMock} />, { wrapper: Wrapper } ); expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.tsx similarity index 85% rename from x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.tsx index 09cdaa26004bb5..36b370b4069aea 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/CustomLinkToolbar.tsx @@ -14,12 +14,12 @@ import { import { i18n } from '@kbn/i18n'; import { APMLink } from '../../Links/apm/APMLink'; -export function ManageCustomLink({ - onCreateCustomLinkClick, - showCreateCustomLinkButton = true, +export function CustomLinkToolbar({ + onClickCreate, + showCreateButton = true, }: { - onCreateCustomLinkClick: () => void; - showCreateCustomLinkButton?: boolean; + onClickCreate: () => void; + showCreateButton?: boolean; }) { return ( <EuiFlexGroup> @@ -41,12 +41,12 @@ export function ManageCustomLink({ </APMLink> </EuiToolTip> </EuiFlexItem> - {showCreateCustomLinkButton && ( + {showCreateButton && ( <EuiFlexItem grow={false}> <EuiButtonEmpty iconType="plusInCircle" size="xs" - onClick={onCreateCustomLinkClick} + onClick={onClickCreate} > {i18n.translate('xpack.apm.customLink.buttom.create.title', { defaultMessage: 'Create', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx similarity index 61% rename from x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx index 5abeae265dfa6b..db7a284f6adff6 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx @@ -7,11 +7,11 @@ import { act, fireEvent, render } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { CustomLink } from '.'; +import { CustomLinkMenuSection } from '.'; import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import * as useFetcher from '../../../../hooks/useFetcher'; import { expectTextsInDocument, expectTextsNotInDocument, @@ -25,16 +25,27 @@ function Wrapper({ children }: { children?: ReactNode }) { ); } +const transaction = ({ + service: { + name: 'name', + environment: 'env', + }, + transaction: { + name: 'tx name', + type: 'tx type', + }, +} as unknown) as Transaction; + describe('Custom links', () => { it('shows empty message when no custom link is available', () => { + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: [], + status: useFetcher.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const component = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); @@ -45,14 +56,14 @@ describe('Custom links', () => { }); it('shows loading while custom links are fetched', () => { + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: [], + status: useFetcher.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }); + const { getByTestId } = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.LOADING} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); expect(getByTestId('loading-spinner')).toBeInTheDocument(); @@ -65,61 +76,68 @@ describe('Custom links', () => { { id: '3', label: 'baz', url: 'baz' }, { id: '4', label: 'qux', url: 'qux' }, ] as CustomLinkType[]; + + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: customLinks, + status: useFetcher.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); expectTextsInDocument(component, ['foo', 'bar', 'baz']); expectTextsNotInDocument(component, ['qux']); }); - it('clicks on See more button', () => { + it('clicks "show all" and "show fewer"', () => { const customLinks = [ { id: '1', label: 'foo', url: 'foo' }, { id: '2', label: 'bar', url: 'bar' }, { id: '3', label: 'baz', url: 'baz' }, { id: '4', label: 'qux', url: 'qux' }, ] as CustomLinkType[]; - const onSeeMoreClickMock = jest.fn(); + + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: customLinks, + status: useFetcher.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={onSeeMoreClickMock} - status={FETCH_STATUS.SUCCESS} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); - expect(onSeeMoreClickMock).not.toHaveBeenCalled(); + + expect(component.getAllByRole('listitem').length).toEqual(3); + act(() => { + fireEvent.click(component.getByText('Show all')); + }); + expect(component.getAllByRole('listitem').length).toEqual(4); act(() => { - fireEvent.click(component.getByText('See more')); + fireEvent.click(component.getByText('Show fewer')); }); - expect(onSeeMoreClickMock).toHaveBeenCalled(); + expect(component.getAllByRole('listitem').length).toEqual(3); }); describe('create custom link buttons', () => { it('shows create button below empty message', () => { + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: [], + status: useFetcher.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const component = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); expectTextsInDocument(component, ['Create custom link']); expectTextsNotInDocument(component, ['Create']); }); + it('shows create button besides the title', () => { const customLinks = [ { id: '1', label: 'foo', url: 'foo' }, @@ -127,14 +145,15 @@ describe('Custom links', () => { { id: '3', label: 'baz', url: 'baz' }, { id: '4', label: 'qux', url: 'qux' }, ] as CustomLinkType[]; + + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + data: customLinks, + status: useFetcher.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - />, + <CustomLinkMenuSection transaction={transaction} />, { wrapper: Wrapper } ); expectTextsInDocument(component, ['Create']); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx new file mode 100644 index 00000000000000..2825363b101976 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo, useState } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { + ActionMenuDivider, + Section, + SectionSubtitle, + SectionTitle, +} from '../../../../../../observability/public'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { CustomLinkList } from './CustomLinkList'; +import { CustomLinkToolbar } from './CustomLinkToolbar'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/useFetcher'; +import { LoadingStatePrompt } from '../../LoadingStatePrompt'; +import { px } from '../../../../style/variables'; +import { CreateEditCustomLinkFlyout } from '../../../app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout'; +import { convertFiltersToQuery } from '../../../app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/helper'; +import { + CustomLink, + Filter, +} from '../../../../../common/custom_link/custom_link_types'; + +const DEFAULT_LINKS_TO_SHOW = 3; + +export function CustomLinkMenuSection({ + transaction, +}: { + transaction: Transaction; +}) { + const [showAllLinks, setShowAllLinks] = useState(false); + const [isCreateEditFlyoutOpen, setIsCreateEditFlyoutOpen] = useState(false); + + const filters = useMemo( + () => + [ + { key: 'service.name', value: transaction?.service.name }, + { key: 'service.environment', value: transaction?.service.environment }, + { key: 'transaction.name', value: transaction?.transaction.name }, + { key: 'transaction.type', value: transaction?.transaction.type }, + ].filter((filter): filter is Filter => typeof filter.value === 'string'), + [transaction] + ); + + const { data: customLinks = [], status, refetch } = useFetcher( + (callApmApi) => + callApmApi({ + isCachable: true, + endpoint: 'GET /api/apm/settings/custom_links', + params: { query: convertFiltersToQuery(filters) }, + }), + [filters] + ); + + return ( + <> + {isCreateEditFlyoutOpen && ( + <CreateEditCustomLinkFlyout + defaults={{ filters }} + onClose={() => { + setIsCreateEditFlyoutOpen(false); + }} + onSave={() => { + setIsCreateEditFlyoutOpen(false); + refetch(); + }} + onDelete={() => { + setIsCreateEditFlyoutOpen(false); + refetch(); + }} + /> + )} + + <ActionMenuDivider /> + + <Section> + <EuiFlexGroup> + <EuiFlexItem> + <SectionTitle> + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.section', + { + defaultMessage: 'Custom Links', + } + )} + </SectionTitle> + </EuiFlexItem> + <EuiFlexItem> + <CustomLinkToolbar + onClickCreate={() => setIsCreateEditFlyoutOpen(true)} + showCreateButton={customLinks.length > 0} + /> + </EuiFlexItem> + </EuiFlexGroup> + + <EuiSpacer size="s" /> + <SectionSubtitle> + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.subtitle', + { + defaultMessage: 'Links will open in a new window.', + } + )} + </SectionSubtitle> + <CustomLinkList + customLinks={ + showAllLinks + ? customLinks + : customLinks.slice(0, DEFAULT_LINKS_TO_SHOW) + } + transaction={transaction} + /> + <EuiSpacer size="s" /> + <BottomSection + status={status} + customLinks={customLinks} + showAllLinks={showAllLinks} + toggleShowAll={() => setShowAllLinks((show) => !show)} + onClickCreate={() => setIsCreateEditFlyoutOpen(true)} + /> + </Section> + </> + ); +} + +function BottomSection({ + status, + customLinks, + showAllLinks, + toggleShowAll, + onClickCreate, +}: { + status: FETCH_STATUS; + customLinks: CustomLink[]; + showAllLinks: boolean; + toggleShowAll: () => void; + onClickCreate: () => void; +}) { + if (status === FETCH_STATUS.LOADING) { + return <LoadingStatePrompt />; + } + + // render empty prompt if there are no custom links + if (isEmpty(customLinks)) { + return ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiText size="xs" grow={false} style={{ width: px(300) }}> + {i18n.translate('xpack.apm.customLink.empty', { + defaultMessage: + 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.', + })} + </EuiText> + <EuiSpacer size="s" /> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onClickCreate} + > + {i18n.translate('xpack.apm.customLink.buttom.create', { + defaultMessage: 'Create custom link', + })} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ); + } + + // render button to toggle "Show all" / "Show fewer" + if (customLinks.length > DEFAULT_LINKS_TO_SHOW) { + return ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiButtonEmpty + iconType={showAllLinks ? 'arrowUp' : 'arrowDown'} + onClick={toggleShowAll} + > + <EuiText size="s"> + {showAllLinks + ? i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.showFewer', + { defaultMessage: 'Show fewer' } + ) + : i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.showAll', + { defaultMessage: 'Show all' } + )} + </EuiText> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ); + } + + return null; +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index f5a57544209f57..15a85113406e15 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -6,7 +6,7 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; import { ActionMenu, @@ -17,16 +17,11 @@ import { SectionSubtitle, SectionTitle, } from '../../../../../observability/public'; -import { Filter } from '../../../../common/custom_link/custom_link_types'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; -import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { CustomLinkFlyout } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout'; -import { convertFiltersToQuery } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper'; -import { CustomLink } from './CustomLink'; -import { CustomLinkPopover } from './CustomLink/CustomLinkPopover'; +import { CustomLinkMenuSection } from './CustomLinkMenuSection'; import { getSections } from './sections'; interface Props { @@ -45,37 +40,13 @@ function ActionMenuButton({ onClick }: { onClick: () => void }) { export function TransactionActionMenu({ transaction }: Props) { const license = useLicense(); - const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + const hasGoldLicense = license?.isActive && license?.hasAtLeast('gold'); const { core } = useApmPluginContext(); const location = useLocation(); const { urlParams } = useUrlParams(); const [isActionPopoverOpen, setIsActionPopoverOpen] = useState(false); - const [isCustomLinksPopoverOpen, setIsCustomLinksPopoverOpen] = useState( - false - ); - const [isCustomLinkFlyoutOpen, setIsCustomLinkFlyoutOpen] = useState(false); - - const filters = useMemo( - () => - [ - { key: 'service.name', value: transaction?.service.name }, - { key: 'service.environment', value: transaction?.service.environment }, - { key: 'transaction.name', value: transaction?.transaction.name }, - { key: 'transaction.type', value: transaction?.transaction.type }, - ].filter((filter): filter is Filter => typeof filter.value === 'string'), - [transaction] - ); - - const { data: customLinks = [], status, refetch } = useFetcher( - (callApmApi) => - callApmApi({ - endpoint: 'GET /api/apm/settings/custom_links', - params: { query: convertFiltersToQuery(filters) }, - }), - [filters] - ); const sections = getSections({ transaction, @@ -84,39 +55,11 @@ export function TransactionActionMenu({ transaction }: Props) { urlParams, }); - const closePopover = () => { - setIsActionPopoverOpen(false); - setIsCustomLinksPopoverOpen(false); - }; - - const toggleCustomLinkFlyout = () => { - closePopover(); - setIsCustomLinkFlyoutOpen((isOpen) => !isOpen); - }; - - const toggleCustomLinkPopover = () => { - setIsCustomLinksPopoverOpen((isOpen) => !isOpen); - }; - return ( <> - {isCustomLinkFlyoutOpen && ( - <CustomLinkFlyout - defaults={{ filters }} - onClose={toggleCustomLinkFlyout} - onSave={() => { - toggleCustomLinkFlyout(); - refetch(); - }} - onDelete={() => { - toggleCustomLinkFlyout(); - refetch(); - }} - /> - )} <ActionMenu id="transactionActionMenu" - closePopover={closePopover} + closePopover={() => setIsActionPopoverOpen(false)} isOpen={isActionPopoverOpen} anchorPosition="downRight" button={ @@ -124,52 +67,34 @@ export function TransactionActionMenu({ transaction }: Props) { } > <div> - {isCustomLinksPopoverOpen ? ( - <CustomLinkPopover - customLinks={customLinks.slice(3, customLinks.length)} - onCreateCustomLinkClick={toggleCustomLinkFlyout} - onClose={toggleCustomLinkPopover} - transaction={transaction} - /> - ) : ( - <> - {sections.map((section, idx) => { - const isLastSection = idx !== sections.length - 1; - return ( - <div key={idx}> - {section.map((item) => ( - <Section key={item.key}> - {item.title && ( - <SectionTitle>{item.title}</SectionTitle> - )} - {item.subtitle && ( - <SectionSubtitle>{item.subtitle}</SectionSubtitle> - )} - <SectionLinks> - {item.actions.map((action) => ( - <SectionLink - key={action.key} - label={action.label} - href={action.href} - /> - ))} - </SectionLinks> - </Section> - ))} - {isLastSection && <ActionMenuDivider />} - </div> - ); - })} - {hasValidLicense && ( - <CustomLink - customLinks={customLinks} - status={status} - onCreateCustomLinkClick={toggleCustomLinkFlyout} - onSeeMoreClick={toggleCustomLinkPopover} - transaction={transaction} - /> - )} - </> + {sections.map((section, idx) => { + const isLastSection = idx !== sections.length - 1; + return ( + <div key={idx}> + {section.map((item) => ( + <Section key={item.key}> + {item.title && <SectionTitle>{item.title}</SectionTitle>} + {item.subtitle && ( + <SectionSubtitle>{item.subtitle}</SectionSubtitle> + )} + <SectionLinks> + {item.actions.map((action) => ( + <SectionLink + key={action.key} + label={action.label} + href={action.href} + /> + ))} + </SectionLinks> + </Section> + ))} + {isLastSection && <ActionMenuDivider />} + </div> + ); + })} + + {hasGoldLicense && ( + <CustomLinkMenuSection transaction={transaction} /> )} </div> </ActionMenu> diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx index 05cae589c19fc6..677e4b7593ff10 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -8,6 +8,7 @@ import { AreaSeries, Axis, Chart, + CurveType, niceTimeFormatter, Placement, Position, @@ -103,6 +104,7 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { stackAccessors={['x']} stackMode={'percentage'} color={serie.areaColor} + curve={CurveType.CURVE_MONOTONE_X} /> ); }) diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx deleted file mode 100644 index 9fc16ab0f9eab9..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { VerticalGridLines } from 'react-vis'; -import { - EuiIcon, - EuiToolTip, - EuiFlexGroup, - EuiFlexItem, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; -import { useTheme } from '../../../../hooks/useTheme'; -import { Maybe } from '../../../../../typings/common'; -import { Annotation } from '../../../../../common/annotations'; -import { PlotValues, SharedPlot } from './plotUtils'; - -interface Props { - annotations: Annotation[]; - plotValues: PlotValues; - width: number; - overlay: Maybe<HTMLElement>; -} - -export function AnnotationsPlot({ plotValues, annotations }: Props) { - const theme = useTheme(); - const tickValues = annotations.map((annotation) => annotation['@timestamp']); - - const style = { - stroke: theme.eui.euiColorSecondary, - strokeDasharray: 'none', - }; - - return ( - <> - <SharedPlot plotValues={plotValues}> - <VerticalGridLines tickValues={tickValues} style={style} /> - </SharedPlot> - {annotations.map((annotation) => ( - <div - key={annotation.id} - style={{ - position: 'absolute', - left: plotValues.x(annotation['@timestamp']) - 8, - top: -2, - }} - > - <EuiToolTip - title={asAbsoluteDateTime(annotation['@timestamp'], 'seconds')} - content={ - <EuiFlexGroup> - <EuiFlexItem grow={true}> - <EuiText> - {i18n.translate('xpack.apm.version', { - defaultMessage: 'Version', - })} - </EuiText> - </EuiFlexItem> - <EuiFlexItem grow={false}>{annotation.text}</EuiFlexItem> - </EuiFlexGroup> - } - > - <EuiIcon type="dot" color={theme.eui.euiColorSecondary} /> - </EuiToolTip> - </div> - ))} - </> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/CustomPlot.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/CustomPlot.stories.tsx deleted file mode 100644 index e70c53108cb0e5..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/CustomPlot.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; -// @ts-expect-error -import CustomPlot from './'; - -storiesOf('shared/charts/CustomPlot', module).add( - 'with annotations but no data', - () => { - const annotations = [ - { - type: 'version', - id: '2020-06-10 04:36:31', - '@timestamp': 1591763925012, - text: '2020-06-10 04:36:31', - }, - { - type: 'version', - id: '2020-06-10 15:23:01', - '@timestamp': 1591802689233, - text: '2020-06-10 15:23:01', - }, - ]; - return <CustomPlot annotations={annotations} series={[]} />; - }, - { - info: { - source: false, - text: - "When a chart has no data but does have annotations, the annotations shouldn't show up at all.", - }, - } -); diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js deleted file mode 100644 index 5aa315d599e18e..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { isEmpty } from 'lodash'; -import { SharedPlot } from './plotUtils'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import SelectionMarker from './SelectionMarker'; - -import { MarkSeries, VerticalGridLines } from 'react-vis'; -import Tooltip from '../Tooltip'; - -function getPointByX(serie, x) { - return serie.data.find((point) => point.x === x); -} - -class InteractivePlot extends PureComponent { - getMarkPoints = (hoverX) => { - return ( - this.props.series - .filter((serie) => - serie.data.some((point) => point.x === hoverX && point.y != null) - ) - .map((serie) => { - const { x, y } = getPointByX(serie, hoverX) || {}; - return { - x, - y, - color: serie.color, - }; - }) - // needs to be reversed, as StaticPlot.js does the same - .reverse() - ); - }; - - getTooltipPoints = (hoverX) => { - return this.props.series - .filter((series) => !series.hideTooltipValue) - .map((serie) => { - const point = getPointByX(serie, hoverX) || {}; - return { - color: serie.color, - value: this.props.formatTooltipValue(point), - text: serie.titleShort || serie.title, - }; - }); - }; - - render() { - const { - plotValues, - hoverX, - series, - isDrawing, - selectionStart, - selectionEnd, - } = this.props; - - if (isEmpty(series)) { - return null; - } - - const tooltipPoints = this.getTooltipPoints(hoverX); - const markPoints = this.getMarkPoints(hoverX); - const { x, xTickValues, yTickValues } = plotValues; - const yValueMiddle = yTickValues[1]; - - if (isEmpty(xTickValues)) { - return <SharedPlot plotValues={plotValues} />; - } - - return ( - <SharedPlot plotValues={plotValues}> - {hoverX && ( - <Tooltip tooltipPoints={tooltipPoints} x={hoverX} y={yValueMiddle} /> - )} - - {hoverX && <MarkSeries data={markPoints} colorType="literal" />} - {hoverX && <VerticalGridLines tickValues={[hoverX]} />} - - {isDrawing && selectionEnd !== null && ( - <SelectionMarker start={x(selectionStart)} end={x(selectionEnd)} /> - )} - </SharedPlot> - ); - } -} - -InteractivePlot.propTypes = { - formatTooltipValue: PropTypes.func.isRequired, - hoverX: PropTypes.number, - isDrawing: PropTypes.bool.isRequired, - plotValues: PropTypes.object.isRequired, - selectionEnd: PropTypes.number, - selectionStart: PropTypes.number, - series: PropTypes.array.isRequired, -}; - -export default InteractivePlot; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js deleted file mode 100644 index 2c4cc185dac7eb..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from 'styled-components'; -import { Legend } from '../Legend'; -import { useTheme } from '../../../../hooks/useTheme'; -import { - unit, - units, - fontSizes, - px, - truncate, -} from '../../../../style/variables'; -import { i18n } from '@kbn/i18n'; -import { EuiIcon } from '@elastic/eui'; - -const Container = styled.div` - display: flex; - margin-left: ${px(unit * 5)}; - flex-wrap: wrap; - - /* add margin to all direct descendant divs */ - & > div { - margin-top: ${px(units.half)}; - margin-right: ${px(unit)}; - &:last-child { - margin-right: 0; - } - } -`; - -const LegendContent = styled.span` - white-space: nowrap; - color: ${({ theme }) => theme.eui.euiColorMediumShade}; - display: flex; -`; - -const TruncatedLabel = styled.span` - display: inline-block; - ${truncate(px(units.half * 10))}; -`; - -const SeriesValue = styled.span` - margin-left: ${px(units.quarter)}; - color: ${({ theme }) => theme.eui.euiColorFullShade}; - display: inline-block; -`; - -const MoreSeriesContainer = styled.div` - font-size: ${fontSizes.small}; - color: ${({ theme }) => theme.eui.euiColorMediumShade}; -`; - -function MoreSeries({ hiddenSeriesCount }) { - if (hiddenSeriesCount <= 0) { - return null; - } - - return ( - <MoreSeriesContainer> - (+ - {hiddenSeriesCount}) - </MoreSeriesContainer> - ); -} - -export default function Legends({ - clickLegend, - hiddenSeriesCount, - noHits, - series, - seriesEnabledState, - truncateLegends, - hasAnnotations, - showAnnotations, - onAnnotationsToggle, -}) { - const theme = useTheme(); - - if (noHits && !hasAnnotations) { - return null; - } - - return ( - <Container> - {series.map((serie, i) => { - if (serie.hideLegend) { - return null; - } - - const text = ( - <LegendContent> - {truncateLegends ? ( - <TruncatedLabel title={serie.title}>{serie.title}</TruncatedLabel> - ) : ( - serie.title - )} - {serie.legendValue && ( - <SeriesValue>{serie.legendValue}</SeriesValue> - )} - </LegendContent> - ); - return ( - <Legend - key={i} - onClick={ - serie.legendClickDisabled ? undefined : () => clickLegend(i) - } - disabled={seriesEnabledState[i]} - text={text} - color={serie.color} - /> - ); - })} - {hasAnnotations && ( - <Legend - key="annotations" - onClick={() => { - if (onAnnotationsToggle) { - onAnnotationsToggle(); - } - }} - text={ - <LegendContent> - {i18n.translate('xpack.apm.serviceVersion', { - defaultMessage: 'Service version', - })} - </LegendContent> - } - indicator={() => ( - <div style={{ marginRight: px(units.quarter) }}> - <EuiIcon type="annotation" color={theme.eui.euiColorSecondary} /> - </div> - )} - disabled={!showAnnotations} - color={theme.eui.euiColorSecondary} - /> - )} - <MoreSeries hiddenSeriesCount={hiddenSeriesCount} /> - </Container> - ); -} - -Legends.propTypes = { - clickLegend: PropTypes.func.isRequired, - hiddenSeriesCount: PropTypes.number.isRequired, - noHits: PropTypes.bool.isRequired, - series: PropTypes.array.isRequired, - seriesEnabledState: PropTypes.array.isRequired, - truncateLegends: PropTypes.bool.isRequired, - hasAnnotations: PropTypes.bool, - showAnnotations: PropTypes.bool, - onAnnotationsToggle: PropTypes.func, -}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js deleted file mode 100644 index a4286578d44d16..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; - -function SelectionMarker({ innerHeight, marginTop, start, end }) { - const width = Math.abs(end - start); - const x = start < end ? start : end; - return ( - <rect - pointerEvents="none" - fill="black" - fillOpacity="0.1" - x={x} - y={marginTop} - width={width} - height={innerHeight} - /> - ); -} - -SelectionMarker.requiresSVG = true; -SelectionMarker.propTypes = { - start: PropTypes.number, - end: PropTypes.number, -}; - -export default SelectionMarker; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js deleted file mode 100644 index e49899da85e0d0..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - XAxis, - YAxis, - HorizontalGridLines, - LineSeries, - LineMarkSeries, - AreaSeries, - VerticalRectSeries, -} from 'react-vis'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import { last } from 'lodash'; -import { rgba } from 'polished'; -import { scaleUtc } from 'd3-scale'; - -import StatusText from './StatusText'; -import { SharedPlot } from './plotUtils'; -import { i18n } from '@kbn/i18n'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { getTimezoneOffsetInMs } from './getTimezoneOffsetInMs'; - -// undefined values are converted by react-vis into NaN when stacking -// see https://github.com/uber/react-vis/issues/1214 -const getNull = (d) => isValidCoordinateValue(d.y) && !isNaN(d.y); - -class StaticPlot extends PureComponent { - getVisSeries(series, plotValues) { - return series - .slice() - .reverse() - .map((serie) => this.getSerie(serie, plotValues)); - } - - getSerie(serie, plotValues) { - switch (serie.type) { - case 'line': - return ( - <LineSeries - getNull={getNull} - key={serie.title} - xType="time-utc" - curve={'curveMonotoneX'} - data={serie.data} - color={serie.color} - stack={serie.stack} - /> - ); - case 'area': - return ( - <AreaSeries - getNull={getNull} - key={serie.title} - xType="time-utc" - curve={'curveMonotoneX'} - data={serie.data} - color={serie.color} - stroke={serie.color} - fill={serie.areaColor || rgba(serie.color, 0.3)} - /> - ); - - case 'areaStacked': { - // convert null into undefined because of stack issues, - // see https://github.com/uber/react-vis/issues/1214 - const data = serie.data.map((value) => { - return 'y' in value && isValidCoordinateValue(value.y) - ? value - : { ...value, y: undefined }; - }); - - // make sure individual markers are displayed in cases - // where there are gaps - - const markersForGaps = serie.data.map((value, index) => { - const prevHasData = getNull(serie.data[index - 1] ?? {}); - const nextHasData = getNull(serie.data[index + 1] ?? {}); - const thisHasData = getNull(value); - - const isGap = !prevHasData && !nextHasData && thisHasData; - - if (!isGap) { - return { - ...value, - y: undefined, - }; - } - - return value; - }); - - return [ - <AreaSeries - getNull={getNull} - key={`${serie.title}-area`} - xType="time-utc" - curve={'curveMonotoneX'} - data={data} - color={serie.color} - stroke={'rgba(0,0,0,0)'} - fill={serie.areaColor || rgba(serie.color, 0.3)} - stack={true} - cluster="area" - />, - <LineSeries - getNull={getNull} - key={`${serie.title}-line`} - xType="time-utc" - curve={'curveMonotoneX'} - data={data} - color={serie.color} - stack={true} - cluster="line" - />, - <LineMarkSeries - getNull={getNull} - key={`${serie.title}-line-markers`} - xType="time-utc" - curve={'curveMonotoneX'} - data={markersForGaps} - stroke={serie.color} - color={serie.color} - lineStyle={{ - opacity: 0, - }} - stack={true} - cluster="line-mark" - size={1} - />, - ]; - } - - case 'areaMaxHeight': - const yMax = last(plotValues.yTickValues); - const data = serie.data.map((p) => ({ - x0: p.x0, - x: p.x, - y0: 0, - y: yMax, - })); - - return ( - <VerticalRectSeries - getNull={getNull} - key={serie.title} - xType="time-utc" - curve={'curveMonotoneX'} - data={data} - color={serie.color} - stroke={serie.color} - fill={serie.areaColor} - /> - ); - case 'linemark': - return ( - <LineMarkSeries - getNull={getNull} - key={serie.title} - xType="time-utc" - curve={'curveMonotoneX'} - data={serie.data} - color={serie.color} - size={1} - /> - ); - default: - throw new Error(`Unknown type ${serie.type}`); - } - } - - /** - * A tick format function that takes the timezone from Kibana's settings into - * account. Used if no tickFormatX prop is supplied. - * - * This produces the same results as the built-in formatter from D3, which is - * what react-vis uses, but shifts the timezone. - */ - tickFormatXTime = (value) => { - const xDomain = this.props.plotValues.x.domain(); - - const time = value.getTime(); - - return scaleUtc().domain(xDomain).tickFormat()( - new Date(time - getTimezoneOffsetInMs(time)) - ); - }; - - render() { - const { series, tickFormatY, plotValues, noHits } = this.props; - const { xTickValues, yTickValues } = plotValues; - - const tickFormatX = this.props.tickFormatX || this.tickFormatXTime; - - return ( - <SharedPlot plotValues={plotValues}> - <XAxis - type="time-utc" - tickSize={0} - tickFormat={tickFormatX} - tickValues={xTickValues} - /> - {noHits ? ( - <StatusText - marginLeft={30} - text={i18n.translate('xpack.apm.metrics.plot.noDataLabel', { - defaultMessage: 'No data within this time range.', - })} - /> - ) : ( - [ - <HorizontalGridLines key="grid-lines" tickValues={yTickValues} />, - <YAxis - key="y-axis" - tickSize={0} - tickValues={yTickValues} - tickFormat={tickFormatY} - style={{ - line: { stroke: 'none', fill: 'none' }, - }} - />, - this.getVisSeries(series, plotValues), - ] - )} - </SharedPlot> - ); - } -} - -export default StaticPlot; - -StaticPlot.propTypes = { - noHits: PropTypes.bool.isRequired, - series: PropTypes.array.isRequired, - plotValues: PropTypes.object.isRequired, - tickFormatX: PropTypes.func, - tickFormatY: PropTypes.func.isRequired, - width: PropTypes.number.isRequired, -}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js deleted file mode 100644 index 51cb3c3885765e..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; - -/** - * NOTE: The margin props in this component are being magically - * set from react-vis by way of the makeFlexibleWidth helper, - * unless specifically set and overridden from above. - */ - -function StatusText({ - marginLeft, - marginRight, - marginTop, - marginBottom, - text, -}) { - const xTransform = `calc(-50% + ${marginLeft - marginRight}px)`; - const yTransform = `calc(-50% + ${marginTop - marginBottom}px - 15px)`; - - return ( - <div - style={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: `translate(${xTransform},${yTransform})`, - }} - > - {text} - </div> - ); -} - -StatusText.propTypes = { - text: PropTypes.string, -}; - -export default StatusText; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js deleted file mode 100644 index 26b03672f1c1f3..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { union } from 'lodash'; -import { Voronoi } from 'react-vis'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; - -import { SharedPlot } from './plotUtils'; - -function getXValuesCombined(series) { - return union(...series.map((serie) => serie.data.map((p) => p.x))).map( - (x) => ({ - x, - }) - ); -} - -class VoronoiPlot extends PureComponent { - render() { - const { series, plotValues, noHits } = this.props; - const { XY_MARGIN, XY_HEIGHT, XY_WIDTH, x } = plotValues; - const xValuesCombined = getXValuesCombined(series); - if (!xValuesCombined || noHits) { - return null; - } - - return ( - <SharedPlot - plotValues={plotValues} - onMouseLeave={this.props.onMouseLeave} - > - <Voronoi - extent={[ - [XY_MARGIN.left, XY_MARGIN.top], - [XY_WIDTH, XY_HEIGHT], - ]} - nodes={xValuesCombined} - onHover={this.props.onHover} - onMouseDown={this.props.onMouseDown} - onMouseUp={this.props.onMouseUp} - x={(d) => x(d.x)} - y={() => 0} - /> - </SharedPlot> - ); - } -} - -export default VoronoiPlot; - -VoronoiPlot.propTypes = { - noHits: PropTypes.bool.isRequired, - onHover: PropTypes.func.isRequired, - onMouseDown: PropTypes.func.isRequired, - onMouseLeave: PropTypes.func.isRequired, - onMouseUp: PropTypes.func, - series: PropTypes.array.isRequired, - plotValues: PropTypes.object.isRequired, -}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js deleted file mode 100644 index 501d30b5e2ba12..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { isEmpty, flatten } from 'lodash'; -import { makeWidthFlexible } from 'react-vis'; -import PropTypes from 'prop-types'; -import React, { PureComponent, Fragment } from 'react'; - -import Legends from './Legends'; -import StaticPlot from './StaticPlot'; -import InteractivePlot from './InteractivePlot'; -import VoronoiPlot from './VoronoiPlot'; -import { AnnotationsPlot } from './AnnotationsPlot'; -import { createSelector } from 'reselect'; -import { getPlotValues } from './plotUtils'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; - -const VISIBLE_LEGEND_COUNT = 4; - -function getHiddenLegendCount(series) { - return series.filter((serie) => serie.hideLegend).length; -} - -export class InnerCustomPlot extends PureComponent { - state = { - seriesEnabledState: [], - isDrawing: false, - selectionStart: null, - selectionEnd: null, - showAnnotations: true, - }; - - getEnabledSeries = createSelector( - (state) => state.visibleSeries, - (state) => state.seriesEnabledState, - (visibleSeries, seriesEnabledState) => - visibleSeries.filter((serie, i) => !seriesEnabledState[i]) - ); - - getOptions = createSelector( - (state) => state.width, - (state) => state.yMin, - (state) => state.yMax, - (state) => state.height, - (state) => state.stackBy, - (width, yMin, yMax, height, stackBy) => ({ - width, - yMin, - yMax, - height, - stackBy, - }) - ); - - getPlotValues = createSelector( - (state) => state.visibleSeries, - (state) => state.enabledSeries, - (state) => state.options, - getPlotValues - ); - - getVisibleSeries = createSelector( - (state) => state.series, - (series) => { - return series.slice( - 0, - this.props.visibleLegendCount + getHiddenLegendCount(series) - ); - } - ); - - clickLegend = (i) => { - this.setState(({ seriesEnabledState }) => { - const nextSeriesEnabledState = this.props.series.map((value, _i) => { - const disabledValue = seriesEnabledState[_i]; - return i === _i ? !disabledValue : !!disabledValue; - }); - - if (typeof this.props.onToggleLegend === 'function') { - this.props.onToggleLegend(nextSeriesEnabledState); - } - - return { - seriesEnabledState: nextSeriesEnabledState, - }; - }); - }; - - onMouseLeave = (...args) => { - this.props.onMouseLeave(...args); - }; - - onMouseDown = (node) => - this.setState({ - isDrawing: true, - selectionStart: node.x, - selectionEnd: null, - }); - - onMouseUp = () => { - if (this.state.isDrawing && this.state.selectionEnd !== null) { - const [start, end] = [ - this.state.selectionStart, - this.state.selectionEnd, - ].sort(); - this.props.onSelectionEnd({ start, end }); - } - this.setState({ isDrawing: false }); - }; - - onHover = (node) => { - this.props.onHover(node.x); - - if (this.state.isDrawing) { - this.setState({ selectionEnd: node.x }); - } - }; - - componentDidMount() { - document.body.addEventListener('mouseup', this.onMouseUp); - } - - componentWillUnmount() { - document.body.removeEventListener('mouseup', this.onMouseUp); - } - - render() { - const { - series, - truncateLegends, - width, - annotations, - visibleLegendCount, - } = this.props; - - if (!width) { - return null; - } - - const hiddenSeriesCount = Math.max( - series.length - visibleLegendCount - getHiddenLegendCount(series), - 0 - ); - const visibleSeries = this.getVisibleSeries({ series }); - const enabledSeries = this.getEnabledSeries({ - visibleSeries, - seriesEnabledState: this.state.seriesEnabledState, - }); - const options = this.getOptions(this.props); - - const hasValidCoordinates = flatten(series.map((s) => s.data)).some((p) => - isValidCoordinateValue(p.y) - ); - const noHits = this.props.noHits || !hasValidCoordinates; - - const plotValues = this.getPlotValues({ - visibleSeries, - enabledSeries: enabledSeries, - options, - }); - - if (isEmpty(plotValues)) { - return null; - } - - return ( - <Fragment> - <div style={{ position: 'relative', height: plotValues.XY_HEIGHT }}> - <StaticPlot - width={width} - noHits={noHits} - plotValues={plotValues} - series={enabledSeries} - tickFormatY={this.props.tickFormatY} - tickFormatX={this.props.tickFormatX} - /> - - {this.state.showAnnotations && !isEmpty(annotations) && !noHits && ( - <AnnotationsPlot - plotValues={plotValues} - width={width} - annotations={annotations || []} - /> - )} - - <InteractivePlot - plotValues={plotValues} - hoverX={this.props.hoverX} - series={enabledSeries} - formatTooltipValue={this.props.formatTooltipValue} - isDrawing={this.state.isDrawing} - selectionStart={this.state.selectionStart} - selectionEnd={this.state.selectionEnd} - /> - - <VoronoiPlot - noHits={noHits} - plotValues={plotValues} - series={enabledSeries} - onHover={this.onHover} - onMouseLeave={this.onMouseLeave} - onMouseDown={this.onMouseDown} - /> - </div> - <Legends - noHits={noHits} - truncateLegends={truncateLegends} - series={visibleSeries} - hiddenSeriesCount={hiddenSeriesCount} - clickLegend={this.clickLegend} - seriesEnabledState={this.state.seriesEnabledState} - hasAnnotations={!isEmpty(annotations) && !noHits} - showAnnotations={this.state.showAnnotations} - onAnnotationsToggle={() => { - this.setState(({ showAnnotations }) => ({ - showAnnotations: !showAnnotations, - })); - }} - /> - </Fragment> - ); - } -} - -InnerCustomPlot.propTypes = { - formatTooltipValue: PropTypes.func, - hoverX: PropTypes.number, - onHover: PropTypes.func.isRequired, - onMouseLeave: PropTypes.func.isRequired, - onSelectionEnd: PropTypes.func.isRequired, - series: PropTypes.array.isRequired, - tickFormatY: PropTypes.func, - truncateLegends: PropTypes.bool, - width: PropTypes.number.isRequired, - height: PropTypes.number, - stackBy: PropTypes.string, - annotations: PropTypes.arrayOf( - PropTypes.shape({ - type: PropTypes.string, - id: PropTypes.string, - firstSeen: PropTypes.number, - }) - ), - noHits: PropTypes.bool, - visibleLegendCount: PropTypes.number, - onToggleLegend: PropTypes.func, -}; - -InnerCustomPlot.defaultProps = { - formatTooltipValue: (p) => p.y, - tickFormatX: undefined, - tickFormatY: (y) => y, - truncateLegends: false, - xAxisTickSizeOuter: 0, - noHits: false, - visibleLegendCount: VISIBLE_LEGEND_COUNT, -}; - -export default makeWidthFlexible(InnerCustomPlot); diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts deleted file mode 100644 index 117ec26446de8c..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as plotUtils from './plotUtils'; -import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; - -describe('plotUtils', () => { - describe('getPlotValues', () => { - describe('with empty arguments', () => { - it('returns plotvalues', () => { - expect( - plotUtils.getPlotValues([], [], { height: 1, width: 1 }) - ).toMatchObject({ - XY_HEIGHT: 1, - XY_WIDTH: 1, - }); - }); - }); - - describe('when yMin is given', () => { - it('uses the yMin in the scale', () => { - expect( - plotUtils - .getPlotValues([], [], { height: 1, width: 1, yMin: 100 }) - .y.domain()[0] - ).toEqual(100); - }); - - describe('when yMin is "min"', () => { - it('uses minimum y from the series', () => { - expect( - plotUtils - .getPlotValues( - [ - { data: [{ x: 0, y: 200 }] }, - { data: [{ x: 0, y: 300 }] }, - ] as Array<TimeSeries<Coordinate>>, - [], - { - height: 1, - width: 1, - yMin: 'min', - } - ) - .y.domain()[0] - ).toEqual(200); - }); - }); - }); - - describe('when yMax given', () => { - it('uses yMax', () => { - expect( - plotUtils - .getPlotValues([], [], { - height: 1, - width: 1, - yMax: 500, - }) - .y.domain()[1] - ).toEqual(500); - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx deleted file mode 100644 index 67b7fd31b05bc3..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { isEmpty, flatten } from 'lodash'; -import { scaleLinear } from 'd3-scale'; -import { XYPlot } from 'react-vis'; -import d3 from 'd3'; -import PropTypes from 'prop-types'; -import React from 'react'; - -import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; -import { unit } from '../../../../style/variables'; -import { getDomainTZ, getTimeTicksTZ } from '../helper/timezone'; - -const XY_HEIGHT = unit * 16; -const XY_MARGIN = { - top: unit, - left: unit * 5, - right: unit, - bottom: unit * 2, -}; - -const getXScale = (xMin: number, xMax: number, width: number) => { - return scaleLinear() - .domain([xMin, xMax]) - .range([XY_MARGIN.left, width - XY_MARGIN.right]); -}; - -const getYScale = (yMin: number, yMax: number) => { - return scaleLinear().domain([yMin, yMax]).range([XY_HEIGHT, 0]).nice(); -}; - -function getFlattenedCoordinates( - visibleSeries: Array<TimeSeries<Coordinate>>, - enabledSeries: Array<TimeSeries<Coordinate>> -) { - const enabledCoordinates = flatten(enabledSeries.map((serie) => serie.data)); - if (!isEmpty(enabledCoordinates)) { - return enabledCoordinates; - } - - return flatten(visibleSeries.map((serie) => serie.data)); -} - -export type PlotValues = ReturnType<typeof getPlotValues>; - -export function getPlotValues( - visibleSeries: Array<TimeSeries<Coordinate>>, - enabledSeries: Array<TimeSeries<Coordinate>>, - { - width, - yMin = 0, - yMax = 'max', - height, - stackBy, - }: { - width: number; - yMin?: number | 'min'; - yMax?: number | 'max'; - height: number; - stackBy?: 'x' | 'y'; - } -) { - const flattenedCoordinates = getFlattenedCoordinates( - visibleSeries, - enabledSeries - ); - - const xMin = d3.min(flattenedCoordinates, (d) => d.x); - const xMax = d3.max(flattenedCoordinates, (d) => d.x); - - if (yMax === 'max') { - yMax = d3.max(flattenedCoordinates, (d) => d.y ?? 0); - } - if (yMin === 'min') { - yMin = d3.min(flattenedCoordinates, (d) => d.y ?? 0); - } - - const [xMinZone, xMaxZone] = getDomainTZ(xMin, xMax); - - const xScale = getXScale(xMin, xMax, width); - const yScale = getYScale(yMin, yMax); - - const yMaxNice = yScale.domain()[1]; - const yTickValues = [0, yMaxNice / 2, yMaxNice]; - - // approximate number of x-axis ticks based on the width of the plot. There should by approx 1 tick per 100px - // d3 will determine the exact number of ticks based on the selected range - const xTickTotal = Math.floor(width / 100); - - const xTickValues = getTimeTicksTZ({ - domain: [xMinZone, xMaxZone], - totalTicks: xTickTotal, - width, - }); - - return { - x: xScale, - y: yScale, - xTickValues, - yTickValues, - XY_MARGIN, - XY_HEIGHT: height || XY_HEIGHT, - XY_WIDTH: width, - stackBy, - }; -} - -export function SharedPlot({ - plotValues, - ...props -}: { - plotValues: PlotValues; - children: React.ReactNode; -}) { - const { XY_HEIGHT: height, XY_MARGIN: margin, XY_WIDTH: width } = plotValues; - - return ( - <div - style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }} - > - <XYPlot - dontCheckIfEmpty - height={height} - margin={margin} - xType="time-utc" - width={width} - xDomain={plotValues.x.domain()} - yDomain={plotValues.y.domain()} - stackBy={plotValues.stackBy} - {...props} - /> - </div> - ); -} - -SharedPlot.propTypes = { - plotValues: PropTypes.shape({ - x: PropTypes.func.isRequired, - y: PropTypes.func.isRequired, - XY_WIDTH: PropTypes.number.isRequired, - height: PropTypes.number, - }).isRequired, -}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js deleted file mode 100644 index 9d127c06e0c144..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js +++ /dev/null @@ -1,343 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment'; -import React from 'react'; -import { - disableConsoleWarning, - toJson, - mountWithTheme, -} from '../../../../../utils/testHelpers'; -import { InnerCustomPlot } from '../index'; -import responseWithData from './responseWithData.json'; -import VoronoiPlot from '../VoronoiPlot'; -import InteractivePlot from '../InteractivePlot'; -import { getResponseTimeSeries } from '../../../../../selectors/chartSelectors'; -import { getEmptySeries } from '../getEmptySeries'; - -function getXValueByIndex(index) { - return responseWithData.responseTimes.avg[index].x; -} - -describe('when response has data', () => { - let consoleMock; - let wrapper; - let onHover; - let onMouseLeave; - let onSelectionEnd; - - beforeAll(() => { - consoleMock = disableConsoleWarning('Warning: componentWillReceiveProps'); - }); - - afterAll(() => { - consoleMock.mockRestore(); - }); - - beforeEach(() => { - const series = getResponseTimeSeries({ apmTimeseries: responseWithData }); - onHover = jest.fn(); - onMouseLeave = jest.fn(); - onSelectionEnd = jest.fn(); - wrapper = mountWithTheme( - <InnerCustomPlot - series={series} - onHover={onHover} - onMouseLeave={onMouseLeave} - onSelectionEnd={onSelectionEnd} - width={800} - tickFormatX={(x) => x.getTime()} // Avoid timezone issues in snapshots - /> - ); - - // Spy on render methods to determine if they re-render - jest.spyOn(VoronoiPlot.prototype, 'render').mockClear(); - jest.spyOn(InteractivePlot.prototype, 'render').mockClear(); - }); - - describe('Initially', () => { - it('should have 3 enabled series', () => { - expect(wrapper.find('LineSeries').length).toBe(3); - }); - - it('should have 3 legends ', () => { - const legends = wrapper.find('Legend'); - expect(legends.length).toBe(3); - expect(legends.map((e) => e.props())).toMatchSnapshot(); - }); - - it('should have 3 XY plots', () => { - expect(wrapper.find('StaticPlot XYPlot').length).toBe(1); - expect(wrapper.find('InteractivePlot XYPlot').length).toBe(1); - expect(wrapper.find('VoronoiPlot XYPlot').length).toBe(1); - }); - - it('should have correct state', () => { - expect(wrapper.state().seriesEnabledState).toEqual([]); - expect(wrapper.state().isDrawing).toBe(false); - expect(wrapper.state().selectionStart).toBe(null); - expect(wrapper.state().selectionEnd).toBe(null); - expect(wrapper.state()).toMatchSnapshot(); - }); - - it('should not display tooltip', () => { - expect(wrapper.find('Tooltip').length).toEqual(0); - }); - - it('should have correct markup', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - }); - }); - - describe('Legends', () => { - it('should have initial values when nothing is clicked', () => { - expect(wrapper.state('seriesEnabledState')).toEqual([]); - expect(wrapper.find('StaticPlot').prop('series').length).toBe(3); - }); - - describe('when legend is clicked once', () => { - beforeEach(() => { - wrapper.find('Legend').at(1).simulate('click'); - }); - - it('should have 2 enabled series', () => { - expect(wrapper.find('LineSeries').length).toBe(2); - }); - - it('should add disabled prop to Legends', () => { - expect( - wrapper.find('Legend').map((node) => node.prop('disabled')) - ).toEqual([false, true, false]); - }); - - it('should toggle series ', () => { - expect(wrapper.state('seriesEnabledState')).toEqual([ - false, - true, - false, - ]); - expect(wrapper.find('StaticPlot').prop('series').length).toBe(2); - }); - - it('should re-render VoronoiPlot', () => { - expect(VoronoiPlot.prototype.render.mock.calls.length).toBe(1); - }); - - it('should re-render InteractivePlot', () => { - expect(InteractivePlot.prototype.render.mock.calls.length).toEqual(1); - }); - }); - - describe('when legend is clicked twice', () => { - beforeEach(() => { - wrapper.find('Legend').at(1).simulate('click').simulate('click'); - }); - - it('should toggle series back to initial state', () => { - expect( - wrapper.find('Legend').map((node) => node.prop('disabled')) - ).toEqual([false, false, false]); - - expect(wrapper.state('seriesEnabledState')).toEqual([ - false, - false, - false, - ]); - - expect(wrapper.find('StaticPlot').prop('series').length).toBe(3); - }); - - it('should re-render VoronoiPlot', () => { - expect(VoronoiPlot.prototype.render.mock.calls.length).toBe(2); - }); - - it('should re-render InteractivePlot', () => { - expect(InteractivePlot.prototype.render.mock.calls.length).toEqual(2); - }); - }); - }); - - describe('when hovering over', () => { - const index = 22; - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(index).simulate('mouseOver'); - }); - - it('should call onHover', () => { - expect(onHover).toHaveBeenCalledWith(getXValueByIndex(index)); - }); - }); - - describe('when setting hoverX', () => { - beforeEach(() => { - // Avoid timezone issues in snapshots - jest.spyOn(moment.prototype, 'format').mockImplementation(function () { - return this.unix(); - }); - - // Simulate hovering over multiple buckets - wrapper.setProps({ hoverX: getXValueByIndex(13) }); - wrapper.setProps({ hoverX: getXValueByIndex(14) }); - wrapper.setProps({ hoverX: getXValueByIndex(15) }); - }); - - it('should display tooltip', () => { - expect(wrapper.find('Tooltip').length).toEqual(1); - expect(wrapper.find('Tooltip').prop('tooltipPoints')).toMatchSnapshot(); - }); - - it('should display vertical line at correct time', () => { - expect( - wrapper.find('InteractivePlot VerticalGridLines').prop('tickValues') - ).toEqual([1502283720000]); - }); - - it('should not re-render VoronoiPlot', () => { - expect(VoronoiPlot.prototype.render.mock.calls.length).toBe(0); - }); - - it('should re-render InteractivePlot', () => { - expect(InteractivePlot.prototype.render.mock.calls.length).toEqual(3); - }); - - it('should match snapshots', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - expect(wrapper.state()).toMatchSnapshot(); - }); - }); - - describe('when dragging without releasing', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(10).simulate('mouseDown'); - - wrapper.find('.rv-voronoi__cell').at(20).simulate('mouseOver'); - }); - - it('should display SelectionMarker', () => { - expect(toJson(wrapper.find('SelectionMarker'))).toMatchSnapshot(); - }); - - it('should not call onSelectionEnd', () => { - expect(onSelectionEnd).not.toHaveBeenCalled(); - }); - }); - - describe('when dragging from left to right and releasing', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(10).simulate('mouseDown'); - - wrapper.find('.rv-voronoi__cell').at(20).simulate('mouseOver'); - document.body.dispatchEvent(new Event('mouseup')); - }); - - it('should call onSelectionEnd', () => { - expect(onSelectionEnd).toHaveBeenCalledWith({ - start: 1502283420000, - end: 1502284020000, - }); - }); - }); - - describe('when dragging from right to left and releasing', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(20).simulate('mouseDown'); - - wrapper.find('.rv-voronoi__cell').at(10).simulate('mouseOver'); - document.body.dispatchEvent(new Event('mouseup')); - }); - - it('should call onSelectionEnd', () => { - expect(onSelectionEnd).toHaveBeenCalledWith({ - start: 1502283420000, - end: 1502284020000, - }); - }); - }); - - it('should call onMouseLeave when leaving the XY plot', () => { - wrapper.find('VoronoiPlot svg.rv-xy-plot__inner').simulate('mouseLeave'); - expect(onMouseLeave).toHaveBeenCalledWith(expect.any(Object)); - }); -}); - -describe('when response has no data', () => { - const onHover = jest.fn(); - const onMouseLeave = jest.fn(); - const onSelectionEnd = jest.fn(); - const annotations = [ - { - type: 'version', - id: '2020-06-10 04:36:31', - '@timestamp': 1591763925012, - text: '2020-06-10 04:36:31', - }, - { - type: 'version', - id: '2020-06-10 15:23:01', - '@timestamp': 1591802689233, - text: '2020-06-10 15:23:01', - }, - ]; - - let wrapper; - beforeEach(() => { - const series = getEmptySeries(1451606400000, 1451610000000); - - wrapper = mountWithTheme( - <InnerCustomPlot - annotations={annotations} - series={series} - onHover={onHover} - onMouseLeave={onMouseLeave} - onSelectionEnd={onSelectionEnd} - width={800} - tickFormatX={(x) => x.getTime()} // Avoid timezone issues in snapshots - /> - ); - }); - - describe('Initially', () => { - it('should have 0 legends ', () => { - expect(wrapper.find('Legend').length).toBe(0); - }); - - it('should have 2 XY plots', () => { - expect(wrapper.find('StaticPlot XYPlot').length).toBe(1); - expect(wrapper.find('InteractivePlot XYPlot').length).toBe(1); - expect(wrapper.find('VoronoiPlot XYPlot').length).toBe(0); - }); - - it('should have correct state', () => { - expect(wrapper.state().seriesEnabledState).toEqual([]); - expect(wrapper.state().isDrawing).toBe(false); - expect(wrapper.state().selectionStart).toBe(null); - expect(wrapper.state().selectionEnd).toBe(null); - expect(wrapper.state()).toMatchSnapshot(); - }); - - it('should not display tooltip', () => { - expect(wrapper.find('Tooltip').length).toEqual(0); - }); - - it('should not show annotations', () => { - expect(wrapper.find('AnnotationsPlot')).toHaveLength(0); - }); - - it('should have correct markup', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - it('should have a single series', () => { - expect(wrapper.prop('series').length).toBe(1); - }); - - it('The series is empty and every y-value is null', () => { - expect(wrapper.prop('series')[0].data.every((d) => d.y === null)).toEqual( - true - ); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap deleted file mode 100644 index 20636fa1444797..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap +++ /dev/null @@ -1,6436 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`when response has data Initially should have 3 legends 1`] = ` -Array [ - Object { - "color": "#6092c0", - "disabled": undefined, - "onClick": [Function], - "text": <styled.span> - Avg. - <styled.span> - 468 ms - </styled.span> - </styled.span>, - }, - Object { - "color": "#d6bf57", - "disabled": undefined, - "onClick": [Function], - "text": <styled.span> - 95th percentile - </styled.span>, - }, - Object { - "color": "#da8b45", - "disabled": undefined, - "onClick": [Function], - "text": <styled.span> - 99th percentile - </styled.span>, - }, -] -`; - -exports[`when response has data Initially should have correct markup 1`] = ` -Array [ - <div - style={ - Object { - "height": 256, - "position": "relative", - } - } - > - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className="rv-xy-plot__axis rv-xy-plot__axis--horizontal " - style={Object {}} - transform="translate(80,224)" - > - <line - className="rv-xy-plot__axis__line" - style={Object {}} - x1={0} - x2={704} - y1={0} - y2={0} - /> - <g - className="rv-xy-plot__axis__ticks" - transform="translate(0, 0)" - > - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(70.4, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283000000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(187.73333333333332, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283300000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(305.06666666666666, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283600000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(422.4, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283900000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(539.7333333333333, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502284200000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(657.0666666666667, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502284500000 - </text> - </g> - </g> - </g> - <g - className="rv-xy-plot__grid-lines" - transform="translate(80,16)" - > - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={208} - y2={208} - /> - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={104} - y2={104} - /> - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={0} - y2={0} - /> - </g> - <g - className="rv-xy-plot__axis rv-xy-plot__axis--vertical " - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0,16)" - > - <line - className="rv-xy-plot__axis__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={80} - x2={80} - y1={0} - y2={208} - /> - <g - className="rv-xy-plot__axis__ticks" - transform="translate(80, 0)" - > - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 208)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 0 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 104)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 2500000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 5000000 - </text> - </g> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,177.67069216000004,15.644444444444442,147.3413843200001,23.466666666666665,145.82854374400006C31.288888888888888,144.31570316800003,39.11111111111111,143.55928288,46.93333333333333,143.55928288C54.75555555555555,143.55928288,62.57777777777778,143.69173256533335,70.4,143.95663193600006C78.22222222222223,144.22153130666678,86.04444444444444,150.087546752,93.86666666666666,150.087546752C101.68888888888888,150.087546752,109.5111111111111,143.61986468266673,117.33333333333333,141.36762432000006C125.15555555555555,139.1153839573334,132.9777777777778,138.10678017066667,140.8,136.574104576C148.62222222222223,135.04142898133333,156.44444444444446,134.60761100800002,164.26666666666668,132.171570752C172.0888888888889,129.735530496,179.9111111111111,122.7740017919999,187.73333333333332,121.95786303999996C195.55555555555554,121.14172428800003,203.37777777777777,120.73365491200006,211.2,120.73365491200006C219.0222222222222,120.73365491200006,226.84444444444443,150.33875334399997,234.66666666666666,150.33875334399997C242.48888888888888,150.33875334399997,250.3111111111111,145.21265574400005,258.1333333333333,145.21265574400005C265.9555555555556,145.21265574400005,273.77777777777777,159.49950515199998,281.6,159.49950515199998C289.4222222222222,159.49950515199998,297.24444444444447,159.25604087466667,305.06666666666666,158.76911232C312.8888888888889,158.28218376533334,320.7111111111111,153.13340861866664,328.53333333333336,148.71727519999996C336.35555555555555,144.30114178133329,344.1777777777778,136.23707191466664,352,132.27231180799998C359.8222222222222,128.30755170133332,367.64444444444445,127.55802467199999,375.46666666666664,124.92871456C383.2888888888889,122.299404448,391.1111111111111,116.49645113600002,398.93333333333334,116.49645113600002C406.75555555555553,116.49645113600002,414.5777777777778,142.8818553066667,422.4,147.94231920000001C430.22222222222223,153.00278309333333,438.0444444444444,155.53301504,445.8666666666667,155.53301504C453.68888888888887,155.53301504,461.5111111111111,147.5484465173333,469.3333333333333,141.915090304C477.1555555555555,136.28173409066667,484.97777777777776,121.73287776000008,492.79999999999995,121.73287776000008C500.6222222222222,121.73287776000008,508.4444444444444,147.90280169599995,516.2666666666667,152.63684758399998C524.0888888888888,157.370893472,531.9111111111112,159.73791641600002,539.7333333333333,159.73791641600002C547.5555555555555,159.73791641600002,555.3777777777779,140.4438672,563.2,140.4438672C571.0222222222222,140.4438672,578.8444444444445,150.146582976,586.6666666666667,150.146582976C594.4888888888889,150.146582976,602.3111111111111,121.98686448,610.1333333333333,121.98686448C617.9555555555555,121.98686448,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,133.8337757120002,657.0666666666667,102.0323582720003C664.8888888888889,70.23094083200053,672.7111111111111,17.191495360000836,680.5333333333333,17.191495360000836C688.3555555555555,17.191495360000836,696.1777777777778,112.59574768000043,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#da8b45", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={145.82854374400006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={143.55928288} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={143.95663193600006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={150.087546752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={141.36762432000006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={136.574104576} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={132.171570752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={121.95786303999996} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={120.73365491200006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={150.33875334399997} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={145.21265574400005} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={159.49950515199998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={158.76911232} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={148.71727519999996} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={132.27231180799998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={124.92871456} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={116.49645113600002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={147.94231920000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={155.53301504} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={141.915090304} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={121.73287776000008} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={152.63684758399998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={159.73791641600002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={140.4438672} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={150.146582976} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={121.98686448} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={102.0323582720003} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={17.191495360000836} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,183.10434549333334,15.644444444444442,158.20869098666665,23.466666666666665,157.4191424C31.288888888888888,156.62959381333334,39.11111111111111,156.23481952,46.93333333333333,156.23481952C54.75555555555555,156.23481952,62.57777777777778,160.81596682666668,70.4,161.56425792000002C78.22222222222223,162.31254901333335,86.04444444444444,162.68669456,93.86666666666666,162.68669456C101.68888888888888,162.68669456,109.5111111111111,158.86059904000004,117.33333333333333,158.86059904000004C125.15555555555555,158.86059904000004,132.9777777777778,163.62246992000001,140.8,163.62246992000001C148.62222222222223,163.62246992000001,156.44444444444446,142.73391392000002,164.26666666666668,142.73391392000002C172.0888888888889,142.73391392000002,179.9111111111111,165.8699744,187.73333333333332,165.8699744C195.55555555555554,165.8699744,203.37777777777777,163.6534529066668,211.2,163.52439168000006C219.0222222222222,163.39533045333334,226.84444444444443,163.45986106666672,234.66666666666666,163.33079984C242.48888888888888,163.20173861333328,250.3111111111111,161.4781168,258.1333333333333,161.4781168C265.9555555555556,161.4781168,273.77777777777777,161.87593552,281.6,162.16472064C289.4222222222222,162.45350576,297.24444444444447,162.51342293333335,305.06666666666666,163.21082752C312.8888888888889,163.90823210666667,320.7111111111111,166.81319824,328.53333333333336,166.81319824C336.35555555555555,166.81319824,344.1777777777778,143.21282560000003,352,143.21282560000003C359.8222222222222,143.21282560000003,367.64444444444445,164.71169104,375.46666666666664,164.71169104C383.2888888888889,164.71169104,391.1111111111111,135.88840304000001,398.93333333333334,135.88840304000001C406.75555555555553,135.88840304000001,414.5777777777778,152.6074260533333,422.4,157.5681224C430.22222222222223,162.52881874666667,438.0444444444444,165.65258112,445.8666666666667,165.65258112C453.68888888888887,165.65258112,461.5111111111111,165.61368234666668,469.3333333333333,165.53588480000002C477.1555555555555,165.45808725333336,484.97777777777776,147.713644,492.79999999999995,147.713644C500.6222222222222,147.713644,508.4444444444444,163.65928869333334,516.2666666666667,164.06490256C524.0888888888888,164.47051642666668,531.9111111111112,164.27093592,539.7333333333333,164.67332336C547.5555555555555,165.07571080000002,555.3777777777779,166.4792272,563.2,166.4792272C571.0222222222222,166.4792272,578.8444444444445,152.7591936,586.6666666666667,152.7591936C594.4888888888889,152.7591936,602.3111111111111,156.23893584,610.1333333333333,163.19842032C617.9555555555555,170.15790480000004,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,177.47751424000003,657.0666666666667,161.37461184C664.8888888888889,145.27170944000005,672.7111111111111,111.3825856,680.5333333333333,111.3825856C688.3555555555555,111.3825856,696.1777777777778,159.69129279999999,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#d6bf57", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={157.4191424} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={156.23481952} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={161.56425792000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={162.68669456} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={158.86059904000004} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={163.62246992000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={142.73391392000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={165.8699744} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={163.52439168000006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={163.33079984} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={161.4781168} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={162.16472064} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={163.21082752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={166.81319824} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={143.21282560000003} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={164.71169104} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={135.88840304000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={157.5681224} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={165.65258112} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={165.53588480000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={147.713644} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={164.06490256} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={164.67332336} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={166.4792272} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={152.7591936} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={163.19842032} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={161.37461184} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={111.3825856} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,198.0144506122449,15.644444444444442,188.0289012244898,23.466666666666665,188.0289012244898C31.288888888888888,188.0289012244898,39.11111111111111,190.93245866666666,46.93333333333333,190.93245866666666C54.75555555555555,190.93245866666666,62.57777777777778,190.28154649962812,70.4,189.81180675918367C78.22222222222223,189.34206701873921,86.04444444444444,188.114020224,93.86666666666666,188.114020224C101.68888888888888,188.114020224,109.5111111111111,188.75217580408165,117.33333333333333,188.75217580408165C125.15555555555555,188.75217580408165,132.9777777777778,187.7737856411058,140.8,186.9231112C148.62222222222223,186.07243675889418,156.44444444444446,183.64812915744682,164.26666666666668,183.64812915744682C172.0888888888889,183.64812915744682,179.9111111111111,187.40215166647675,187.73333333333332,188.65222657560977C195.55555555555554,189.9023014847428,203.37777777777777,191.1485786122449,211.2,191.1485786122449C219.0222222222222,191.1485786122449,226.84444444444443,187.99938938181816,234.66666666666666,187.99938938181816C242.48888888888888,187.99938938181816,250.3111111111111,192.51163795348836,258.1333333333333,192.51163795348836C265.9555555555556,192.51163795348836,273.77777777777777,186.99252785777776,281.6,186.99252785777776C289.4222222222222,186.99252785777776,297.24444444444447,191.5321727255814,305.06666666666666,191.5321727255814C312.8888888888889,191.5321727255814,320.7111111111111,188.75657926666668,328.53333333333336,188.75657926666668C336.35555555555555,188.75657926666668,344.1777777777778,189.74989696,352,189.74989696C359.8222222222222,189.74989696,367.64444444444445,189.71163744,375.46666666666664,189.6351184C383.2888888888889,189.55859936000002,391.1111111111111,184.25858141935484,398.93333333333334,184.25858141935484C406.75555555555553,184.25858141935484,414.5777777777778,190.2827607652174,422.4,190.2827607652174C430.22222222222223,190.2827607652174,438.0444444444444,189.76271776603772,445.8666666666667,189.76271776603772C453.68888888888887,189.76271776603772,461.5111111111111,191.83746261333334,469.3333333333333,191.83746261333334C477.1555555555555,191.83746261333334,484.97777777777776,187.9456040347826,492.79999999999995,187.9456040347826C500.6222222222222,187.9456040347826,508.4444444444444,188.09594339288537,516.2666666666667,188.3966221090909C524.0888888888888,188.69730082529645,531.9111111111112,191.762533248,539.7333333333333,191.762533248C547.5555555555555,191.762533248,555.3777777777779,191.6625795195817,563.2,191.4626720627451C571.0222222222222,191.2627646059085,578.8444444444445,189.4011021381818,586.6666666666667,189.4011021381818C594.4888888888889,189.4011021381818,602.3111111111111,189.79567013943304,610.1333333333333,190.58480614193547C617.9555555555555,191.3739421444379,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,194.91739193977511,657.0666666666667,189.69166496603773C664.8888888888889,184.46593799230038,672.7111111111111,176.64563815757575,680.5333333333333,176.64563815757575C688.3555555555555,176.64563815757575,696.1777777777778,192.32281907878786,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#6092c0", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={188.0289012244898} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={190.93245866666666} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={189.81180675918367} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={188.114020224} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={188.75217580408165} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={186.9231112} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={183.64812915744682} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={188.65222657560977} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={191.1485786122449} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={187.99938938181816} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={192.51163795348836} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={186.99252785777776} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={191.5321727255814} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={188.75657926666668} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={189.74989696} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={189.6351184} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={184.25858141935484} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={190.2827607652174} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={189.76271776603772} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={191.83746261333334} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={187.9456040347826} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={188.3966221090909} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={191.762533248} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={191.4626720627451} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={189.4011021381818} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={190.58480614193547} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={189.69166496603773} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={176.64563815757575} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - </g> - </g> - </svg> - </div> - </div> - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - /> - </div> - </div> - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className=" rv-voronoi" - > - <path - className="rv-voronoi__cell " - d="M91.7333335,256L91.7333335,16L80,16L80,256Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M91.7333335,16L91.7333335,256L115.19999999999999,256L115.19999999999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M115.19999999999999,16L115.19999999999999,256L138.6666665,256L138.6666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M138.6666665,16L138.6666665,256L162.1333335,256L162.1333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M162.1333335,16L162.1333335,256L185.59999999999997,256L185.59999999999997,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M185.59999999999997,16L185.59999999999997,256L209.0666665,256L209.0666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M209.0666665,16L209.0666665,256L232.53333349999997,256L232.53333349999997,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M232.53333349999997,16L232.53333349999997,256L256,256L256,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M256,16L256,256L279.4666665,256L279.4666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M279.4666665,16L279.4666665,256L302.9333335,256L302.9333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M302.9333335,16L302.9333335,256L326.4,256L326.4,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M326.4,16L326.4,256L349.86666649999995,256L349.86666649999995,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M349.86666649999995,16L349.86666649999995,256L373.3333335,256L373.3333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M373.3333335,16L373.3333335,256L396.79999999999995,256L396.79999999999995,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M396.79999999999995,16L396.79999999999995,256L420.2666665,256L420.2666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M420.2666665,16L420.2666665,256L443.73333349999996,256L443.73333349999996,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M443.73333349999996,16L443.73333349999996,256L467.2,256L467.2,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M467.2,16L467.2,256L490.6666665,256L490.6666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M490.6666665,16L490.6666665,256L514.1333334999999,256L514.1333334999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M514.1333334999999,16L514.1333334999999,256L537.5999999999999,256L537.5999999999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M537.5999999999999,16L537.5999999999999,256L561.0666664999999,256L561.0666664999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M561.0666664999999,16L561.0666664999999,256L584.5333335,256L584.5333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M584.5333335,16L584.5333335,256L608,256L608,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M608,16L608,256L631.4666665,256L631.4666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M631.4666665,16L631.4666665,256L654.9333334999999,256L654.9333334999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M654.9333334999999,16L654.9333334999999,256L678.4,256L678.4,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M678.4,16L678.4,256L701.8666665000001,256L701.8666665000001,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M701.8666665000001,16L701.8666665000001,256L725.3333335,256L725.3333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M725.3333335,16L725.3333335,256L748.8,256L748.8,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M748.8,16L748.8,256L772.2666664999999,256L772.2666664999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M772.2666664999999,16L772.2666664999999,256L800,256L800,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - </g> - </svg> - </div> - </div> - </div>, - .c1 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - font-size: 12px; - color: #69707d; - cursor: pointer; - opacity: 1; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.c2 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #6092c0; - border-radius: 100%; -} - -.c5 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #d6bf57; - border-radius: 100%; -} - -.c6 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #da8b45; - border-radius: 100%; -} - -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin-left: 80px; - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.c0 > div { - margin-top: 8px; - margin-right: 16px; -} - -.c0 > div:last-child { - margin-right: 0; -} - -.c3 { - white-space: nowrap; - color: #98a2b3; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - -.c4 { - margin-left: 4px; - color: #000000; - display: inline-block; -} - -<styled.div> - <div - className="c0" - > - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#6092c0" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c2" - color="#6092c0" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - Avg. - <styled.span> - <span - className="c4" - > - 468 ms - </span> - </styled.span> - </span> - </styled.span> - </div> - </styled.div> - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#d6bf57" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c5" - color="#d6bf57" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - 95th percentile - </span> - </styled.span> - </div> - </styled.div> - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#da8b45" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c6" - color="#da8b45" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - 99th percentile - </span> - </styled.span> - </div> - </styled.div> - </div> - </styled.div>, -] -`; - -exports[`when response has data Initially should have correct state 1`] = ` -Object { - "isDrawing": false, - "selectionEnd": null, - "selectionStart": null, - "seriesEnabledState": Array [], - "showAnnotations": true, -} -`; - -exports[`when response has data when dragging without releasing should display SelectionMarker 1`] = ` -<rect - fill="black" - fillOpacity="0.1" - height={208} - pointerEvents="none" - width={234.66666666666663} - x={314.66666666666663} - y={16} -/> -`; - -exports[`when response has data when setting hoverX should display tooltip 1`] = ` -Array [ - Object { - "color": "#6092c0", - "text": "Avg.", - "value": 438704.4, - }, - Object { - "color": "#d6bf57", - "text": "95th", - "value": 1557383.999999999, - }, - Object { - "color": "#da8b45", - "text": "99th", - "value": 1820377.1200000006, - }, -] -`; - -exports[`when response has data when setting hoverX should match snapshots 1`] = ` -Array [ - .c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - font-size: 12px; - color: #69707d; - cursor: initial; - opacity: 1; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.c6 { - width: 8px; - height: 8px; - margin-right: 4px; - background: #6092c0; - border-radius: 100%; -} - -.c8 { - width: 8px; - height: 8px; - margin-right: 4px; - background: #d6bf57; - border-radius: 100%; -} - -.c9 { - width: 8px; - height: 8px; - margin-right: 4px; - background: #da8b45; - border-radius: 100%; -} - -.c0 { - margin: 0 16px; - -webkit-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - border: 1px solid #d3dae6; - background: #ffffff; - border-radius: 4px; - font-size: 14px; - color: #000000; -} - -.c1 { - background: #f5f7fa; - border-bottom: 1px solid #d3dae6; - border-radius: 4px 4px 0 0; - padding: 8px; - color: #98a2b3; -} - -.c2 { - margin: 8px; - margin-right: 16px; - font-size: 12px; -} - -.c10 { - color: #98a2b3; - margin: 8px; - font-size: 12px; -} - -.c3 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - margin-bottom: 4px; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c4 { - color: #98a2b3; - padding-bottom: 0; - padding-right: 8px; -} - -.c7 { - color: #69707d; - font-size: 14px; -} - -<div - style={ - Object { - "height": 256, - "position": "relative", - } - } - > - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className="rv-xy-plot__axis rv-xy-plot__axis--horizontal " - style={Object {}} - transform="translate(80,224)" - > - <line - className="rv-xy-plot__axis__line" - style={Object {}} - x1={0} - x2={704} - y1={0} - y2={0} - /> - <g - className="rv-xy-plot__axis__ticks" - transform="translate(0, 0)" - > - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(70.4, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283000000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(187.73333333333332, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283300000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(305.06666666666666, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283600000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(422.4, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502283900000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(539.7333333333333, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502284200000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(657.0666666666667, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1502284500000 - </text> - </g> - </g> - </g> - <g - className="rv-xy-plot__grid-lines" - transform="translate(80,16)" - > - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={208} - y2={208} - /> - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={104} - y2={104} - /> - <line - className="rv-xy-plot__grid-lines__line" - x1={0} - x2={704} - y1={0} - y2={0} - /> - </g> - <g - className="rv-xy-plot__axis rv-xy-plot__axis--vertical " - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0,16)" - > - <line - className="rv-xy-plot__axis__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={80} - x2={80} - y1={0} - y2={208} - /> - <g - className="rv-xy-plot__axis__ticks" - transform="translate(80, 0)" - > - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 208)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 0 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 104)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 2500000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - transform="translate(0, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={ - Object { - "fill": "none", - "line": Object { - "fill": "none", - "stroke": "none", - }, - "stroke": "none", - } - } - x1={0} - x2={-0} - y1={0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.32em" - style={ - Object { - "line": Object { - "fill": "none", - "stroke": "none", - }, - } - } - textAnchor="end" - transform="translate(-8, 0)" - > - 5000000 - </text> - </g> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,177.67069216000004,15.644444444444442,147.3413843200001,23.466666666666665,145.82854374400006C31.288888888888888,144.31570316800003,39.11111111111111,143.55928288,46.93333333333333,143.55928288C54.75555555555555,143.55928288,62.57777777777778,143.69173256533335,70.4,143.95663193600006C78.22222222222223,144.22153130666678,86.04444444444444,150.087546752,93.86666666666666,150.087546752C101.68888888888888,150.087546752,109.5111111111111,143.61986468266673,117.33333333333333,141.36762432000006C125.15555555555555,139.1153839573334,132.9777777777778,138.10678017066667,140.8,136.574104576C148.62222222222223,135.04142898133333,156.44444444444446,134.60761100800002,164.26666666666668,132.171570752C172.0888888888889,129.735530496,179.9111111111111,122.7740017919999,187.73333333333332,121.95786303999996C195.55555555555554,121.14172428800003,203.37777777777777,120.73365491200006,211.2,120.73365491200006C219.0222222222222,120.73365491200006,226.84444444444443,150.33875334399997,234.66666666666666,150.33875334399997C242.48888888888888,150.33875334399997,250.3111111111111,145.21265574400005,258.1333333333333,145.21265574400005C265.9555555555556,145.21265574400005,273.77777777777777,159.49950515199998,281.6,159.49950515199998C289.4222222222222,159.49950515199998,297.24444444444447,159.25604087466667,305.06666666666666,158.76911232C312.8888888888889,158.28218376533334,320.7111111111111,153.13340861866664,328.53333333333336,148.71727519999996C336.35555555555555,144.30114178133329,344.1777777777778,136.23707191466664,352,132.27231180799998C359.8222222222222,128.30755170133332,367.64444444444445,127.55802467199999,375.46666666666664,124.92871456C383.2888888888889,122.299404448,391.1111111111111,116.49645113600002,398.93333333333334,116.49645113600002C406.75555555555553,116.49645113600002,414.5777777777778,142.8818553066667,422.4,147.94231920000001C430.22222222222223,153.00278309333333,438.0444444444444,155.53301504,445.8666666666667,155.53301504C453.68888888888887,155.53301504,461.5111111111111,147.5484465173333,469.3333333333333,141.915090304C477.1555555555555,136.28173409066667,484.97777777777776,121.73287776000008,492.79999999999995,121.73287776000008C500.6222222222222,121.73287776000008,508.4444444444444,147.90280169599995,516.2666666666667,152.63684758399998C524.0888888888888,157.370893472,531.9111111111112,159.73791641600002,539.7333333333333,159.73791641600002C547.5555555555555,159.73791641600002,555.3777777777779,140.4438672,563.2,140.4438672C571.0222222222222,140.4438672,578.8444444444445,150.146582976,586.6666666666667,150.146582976C594.4888888888889,150.146582976,602.3111111111111,121.98686448,610.1333333333333,121.98686448C617.9555555555555,121.98686448,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,133.8337757120002,657.0666666666667,102.0323582720003C664.8888888888889,70.23094083200053,672.7111111111111,17.191495360000836,680.5333333333333,17.191495360000836C688.3555555555555,17.191495360000836,696.1777777777778,112.59574768000043,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#da8b45", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={145.82854374400006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={143.55928288} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={143.95663193600006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={150.087546752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={141.36762432000006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={136.574104576} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={132.171570752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={121.95786303999996} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={120.73365491200006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={150.33875334399997} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={145.21265574400005} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={159.49950515199998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={158.76911232} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={148.71727519999996} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={132.27231180799998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={124.92871456} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={116.49645113600002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={147.94231920000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={155.53301504} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={141.915090304} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={121.73287776000008} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={152.63684758399998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={159.73791641600002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={140.4438672} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={150.146582976} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={121.98686448} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={102.0323582720003} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={17.191495360000836} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,183.10434549333334,15.644444444444442,158.20869098666665,23.466666666666665,157.4191424C31.288888888888888,156.62959381333334,39.11111111111111,156.23481952,46.93333333333333,156.23481952C54.75555555555555,156.23481952,62.57777777777778,160.81596682666668,70.4,161.56425792000002C78.22222222222223,162.31254901333335,86.04444444444444,162.68669456,93.86666666666666,162.68669456C101.68888888888888,162.68669456,109.5111111111111,158.86059904000004,117.33333333333333,158.86059904000004C125.15555555555555,158.86059904000004,132.9777777777778,163.62246992000001,140.8,163.62246992000001C148.62222222222223,163.62246992000001,156.44444444444446,142.73391392000002,164.26666666666668,142.73391392000002C172.0888888888889,142.73391392000002,179.9111111111111,165.8699744,187.73333333333332,165.8699744C195.55555555555554,165.8699744,203.37777777777777,163.6534529066668,211.2,163.52439168000006C219.0222222222222,163.39533045333334,226.84444444444443,163.45986106666672,234.66666666666666,163.33079984C242.48888888888888,163.20173861333328,250.3111111111111,161.4781168,258.1333333333333,161.4781168C265.9555555555556,161.4781168,273.77777777777777,161.87593552,281.6,162.16472064C289.4222222222222,162.45350576,297.24444444444447,162.51342293333335,305.06666666666666,163.21082752C312.8888888888889,163.90823210666667,320.7111111111111,166.81319824,328.53333333333336,166.81319824C336.35555555555555,166.81319824,344.1777777777778,143.21282560000003,352,143.21282560000003C359.8222222222222,143.21282560000003,367.64444444444445,164.71169104,375.46666666666664,164.71169104C383.2888888888889,164.71169104,391.1111111111111,135.88840304000001,398.93333333333334,135.88840304000001C406.75555555555553,135.88840304000001,414.5777777777778,152.6074260533333,422.4,157.5681224C430.22222222222223,162.52881874666667,438.0444444444444,165.65258112,445.8666666666667,165.65258112C453.68888888888887,165.65258112,461.5111111111111,165.61368234666668,469.3333333333333,165.53588480000002C477.1555555555555,165.45808725333336,484.97777777777776,147.713644,492.79999999999995,147.713644C500.6222222222222,147.713644,508.4444444444444,163.65928869333334,516.2666666666667,164.06490256C524.0888888888888,164.47051642666668,531.9111111111112,164.27093592,539.7333333333333,164.67332336C547.5555555555555,165.07571080000002,555.3777777777779,166.4792272,563.2,166.4792272C571.0222222222222,166.4792272,578.8444444444445,152.7591936,586.6666666666667,152.7591936C594.4888888888889,152.7591936,602.3111111111111,156.23893584,610.1333333333333,163.19842032C617.9555555555555,170.15790480000004,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,177.47751424000003,657.0666666666667,161.37461184C664.8888888888889,145.27170944000005,672.7111111111111,111.3825856,680.5333333333333,111.3825856C688.3555555555555,111.3825856,696.1777777777778,159.69129279999999,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#d6bf57", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={157.4191424} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={156.23481952} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={161.56425792000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={162.68669456} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={158.86059904000004} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={163.62246992000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={142.73391392000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={165.8699744} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={163.52439168000006} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={163.33079984} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={161.4781168} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={162.16472064} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={163.21082752} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={166.81319824} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={143.21282560000003} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={164.71169104} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={135.88840304000001} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={157.5681224} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={165.65258112} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={165.53588480000002} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={147.713644} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={164.06490256} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={164.67332336} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={166.4792272} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={152.7591936} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={163.19842032} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={161.37461184} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={111.3825856} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - </g> - </g> - <g - className="rv-xy-plot__series rv-xy-plot__series--linemark" - > - <path - className="rv-xy-plot__series rv-xy-plot__series--line " - d="M0,208C7.822222222222222,198.0144506122449,15.644444444444442,188.0289012244898,23.466666666666665,188.0289012244898C31.288888888888888,188.0289012244898,39.11111111111111,190.93245866666666,46.93333333333333,190.93245866666666C54.75555555555555,190.93245866666666,62.57777777777778,190.28154649962812,70.4,189.81180675918367C78.22222222222223,189.34206701873921,86.04444444444444,188.114020224,93.86666666666666,188.114020224C101.68888888888888,188.114020224,109.5111111111111,188.75217580408165,117.33333333333333,188.75217580408165C125.15555555555555,188.75217580408165,132.9777777777778,187.7737856411058,140.8,186.9231112C148.62222222222223,186.07243675889418,156.44444444444446,183.64812915744682,164.26666666666668,183.64812915744682C172.0888888888889,183.64812915744682,179.9111111111111,187.40215166647675,187.73333333333332,188.65222657560977C195.55555555555554,189.9023014847428,203.37777777777777,191.1485786122449,211.2,191.1485786122449C219.0222222222222,191.1485786122449,226.84444444444443,187.99938938181816,234.66666666666666,187.99938938181816C242.48888888888888,187.99938938181816,250.3111111111111,192.51163795348836,258.1333333333333,192.51163795348836C265.9555555555556,192.51163795348836,273.77777777777777,186.99252785777776,281.6,186.99252785777776C289.4222222222222,186.99252785777776,297.24444444444447,191.5321727255814,305.06666666666666,191.5321727255814C312.8888888888889,191.5321727255814,320.7111111111111,188.75657926666668,328.53333333333336,188.75657926666668C336.35555555555555,188.75657926666668,344.1777777777778,189.74989696,352,189.74989696C359.8222222222222,189.74989696,367.64444444444445,189.71163744,375.46666666666664,189.6351184C383.2888888888889,189.55859936000002,391.1111111111111,184.25858141935484,398.93333333333334,184.25858141935484C406.75555555555553,184.25858141935484,414.5777777777778,190.2827607652174,422.4,190.2827607652174C430.22222222222223,190.2827607652174,438.0444444444444,189.76271776603772,445.8666666666667,189.76271776603772C453.68888888888887,189.76271776603772,461.5111111111111,191.83746261333334,469.3333333333333,191.83746261333334C477.1555555555555,191.83746261333334,484.97777777777776,187.9456040347826,492.79999999999995,187.9456040347826C500.6222222222222,187.9456040347826,508.4444444444444,188.09594339288537,516.2666666666667,188.3966221090909C524.0888888888888,188.69730082529645,531.9111111111112,191.762533248,539.7333333333333,191.762533248C547.5555555555555,191.762533248,555.3777777777779,191.6625795195817,563.2,191.4626720627451C571.0222222222222,191.2627646059085,578.8444444444445,189.4011021381818,586.6666666666667,189.4011021381818C594.4888888888889,189.4011021381818,602.3111111111111,189.79567013943304,610.1333333333333,190.58480614193547C617.9555555555555,191.3739421444379,625.7777777777778,208,633.6,208C641.4222222222222,208,649.2444444444445,194.91739193977511,657.0666666666667,189.69166496603773C664.8888888888889,184.46593799230038,672.7111111111111,176.64563815757575,680.5333333333333,176.64563815757575C688.3555555555555,176.64563815757575,696.1777777777778,192.32281907878786,704,208" - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - style={ - Object { - "opacity": 1, - "stroke": "#6092c0", - "strokeDasharray": undefined, - "strokeWidth": undefined, - } - } - transform="translate(80,16)" - /> - <g - className="rv-xy-plot__series rv-xy-plot__series--mark " - transform="translate(80,16)" - > - <circle - cx={0} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={23.466666666666665} - cy={188.0289012244898} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={46.93333333333333} - cy={190.93245866666666} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={70.4} - cy={189.81180675918367} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={93.86666666666666} - cy={188.114020224} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={117.33333333333333} - cy={188.75217580408165} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={140.8} - cy={186.9231112} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={164.26666666666668} - cy={183.64812915744682} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={187.73333333333332} - cy={188.65222657560977} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={211.2} - cy={191.1485786122449} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={234.66666666666666} - cy={187.99938938181816} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={258.1333333333333} - cy={192.51163795348836} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={281.6} - cy={186.99252785777776} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={305.06666666666666} - cy={191.5321727255814} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={328.53333333333336} - cy={188.75657926666668} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={189.74989696} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={375.46666666666664} - cy={189.6351184} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={398.93333333333334} - cy={184.25858141935484} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={422.4} - cy={190.2827607652174} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={445.8666666666667} - cy={189.76271776603772} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={469.3333333333333} - cy={191.83746261333334} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={492.79999999999995} - cy={187.9456040347826} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={516.2666666666667} - cy={188.3966221090909} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={539.7333333333333} - cy={191.762533248} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={563.2} - cy={191.4626720627451} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={586.6666666666667} - cy={189.4011021381818} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={610.1333333333333} - cy={190.58480614193547} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={633.6} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={657.0666666666667} - cy={189.69166496603773} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={680.5333333333333} - cy={176.64563815757575} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - <circle - cx={704} - cy={208} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={1} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - </g> - </g> - </svg> - </div> - </div> - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className="rv-xy-plot__series rv-xy-plot__series--mark undefined" - transform="translate(80,16)" - > - <circle - cx={352} - cy={132.27231180799998} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={5} - style={ - Object { - "fill": "#da8b45", - "opacity": 1, - "stroke": "#da8b45", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={143.21282560000003} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={5} - style={ - Object { - "fill": "#d6bf57", - "opacity": 1, - "stroke": "#d6bf57", - "strokeWidth": 1, - } - } - /> - <circle - cx={352} - cy={189.74989696} - onClick={[Function]} - onContextMenu={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - r={5} - style={ - Object { - "fill": "#6092c0", - "opacity": 1, - "stroke": "#6092c0", - "strokeWidth": 1, - } - } - /> - </g> - <g - className="rv-xy-plot__grid-lines" - transform="translate(80,16)" - > - <line - className="rv-xy-plot__grid-lines__line" - x1={352} - x2={352} - y1={0} - y2={208} - /> - </g> - </svg> - <div - className="rv-hint rv-hint--horizontalAlign-right - rv-hint--verticalAlign-bottom" - style={ - Object { - "left": 432, - "position": "absolute", - "top": 120, - } - } - > - <styled.div> - <div - className="c0" - > - <styled.div> - <div - className="c1" - > - 1502283720 - </div> - </styled.div> - <styled.div> - <div - className="c2" - > - <styled.div> - <div - className="c3" - > - <Styled(Legend) - color="#6092c0" - radius={8} - text="Avg." - > - <styled.div - className="c4" - clickable={false} - disabled={false} - fontSize="12px" - > - <div - className="c5 c4" - disabled={false} - fontSize="12px" - > - <styled.span - color="#6092c0" - radius={8} - shape="circle" - withMargin={true} - > - <span - className="c6" - color="#6092c0" - radius={8} - shape="circle" - /> - </styled.span> - Avg. - </div> - </styled.div> - </Styled(Legend)> - <styled.div> - <div - className="c7" - > - 438704.4 - </div> - </styled.div> - </div> - </styled.div> - <styled.div> - <div - className="c3" - > - <Styled(Legend) - color="#d6bf57" - radius={8} - text="95th" - > - <styled.div - className="c4" - clickable={false} - disabled={false} - fontSize="12px" - > - <div - className="c5 c4" - disabled={false} - fontSize="12px" - > - <styled.span - color="#d6bf57" - radius={8} - shape="circle" - withMargin={true} - > - <span - className="c8" - color="#d6bf57" - radius={8} - shape="circle" - /> - </styled.span> - 95th - </div> - </styled.div> - </Styled(Legend)> - <styled.div> - <div - className="c7" - > - 1557383.999999999 - </div> - </styled.div> - </div> - </styled.div> - <styled.div> - <div - className="c3" - > - <Styled(Legend) - color="#da8b45" - radius={8} - text="99th" - > - <styled.div - className="c4" - clickable={false} - disabled={false} - fontSize="12px" - > - <div - className="c5 c4" - disabled={false} - fontSize="12px" - > - <styled.span - color="#da8b45" - radius={8} - shape="circle" - withMargin={true} - > - <span - className="c9" - color="#da8b45" - radius={8} - shape="circle" - /> - </styled.span> - 99th - </div> - </styled.div> - </Styled(Legend)> - <styled.div> - <div - className="c7" - > - 1820377.1200000006 - </div> - </styled.div> - </div> - </styled.div> - </div> - </styled.div> - <styled.div> - <div - className="c10" - /> - </styled.div> - </div> - </styled.div> - </div> - </div> - </div> - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className=" rv-voronoi" - > - <path - className="rv-voronoi__cell " - d="M91.7333335,256L91.7333335,16L80,16L80,256Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M91.7333335,16L91.7333335,256L115.19999999999999,256L115.19999999999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M115.19999999999999,16L115.19999999999999,256L138.6666665,256L138.6666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M138.6666665,16L138.6666665,256L162.1333335,256L162.1333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M162.1333335,16L162.1333335,256L185.59999999999997,256L185.59999999999997,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M185.59999999999997,16L185.59999999999997,256L209.0666665,256L209.0666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M209.0666665,16L209.0666665,256L232.53333349999997,256L232.53333349999997,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M232.53333349999997,16L232.53333349999997,256L256,256L256,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M256,16L256,256L279.4666665,256L279.4666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M279.4666665,16L279.4666665,256L302.9333335,256L302.9333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M302.9333335,16L302.9333335,256L326.4,256L326.4,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M326.4,16L326.4,256L349.86666649999995,256L349.86666649999995,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M349.86666649999995,16L349.86666649999995,256L373.3333335,256L373.3333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M373.3333335,16L373.3333335,256L396.79999999999995,256L396.79999999999995,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M396.79999999999995,16L396.79999999999995,256L420.2666665,256L420.2666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M420.2666665,16L420.2666665,256L443.73333349999996,256L443.73333349999996,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M443.73333349999996,16L443.73333349999996,256L467.2,256L467.2,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M467.2,16L467.2,256L490.6666665,256L490.6666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M490.6666665,16L490.6666665,256L514.1333334999999,256L514.1333334999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M514.1333334999999,16L514.1333334999999,256L537.5999999999999,256L537.5999999999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M537.5999999999999,16L537.5999999999999,256L561.0666664999999,256L561.0666664999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M561.0666664999999,16L561.0666664999999,256L584.5333335,256L584.5333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M584.5333335,16L584.5333335,256L608,256L608,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M608,16L608,256L631.4666665,256L631.4666665,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M631.4666665,16L631.4666665,256L654.9333334999999,256L654.9333334999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M654.9333334999999,16L654.9333334999999,256L678.4,256L678.4,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M678.4,16L678.4,256L701.8666665000001,256L701.8666665000001,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M701.8666665000001,16L701.8666665000001,256L725.3333335,256L725.3333335,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M725.3333335,16L725.3333335,256L748.8,256L748.8,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M748.8,16L748.8,256L772.2666664999999,256L772.2666664999999,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - <path - className="rv-voronoi__cell " - d="M772.2666664999999,16L772.2666664999999,256L800,256L800,16Z" - fill="none" - onClick={[Function]} - onMouseDown={[Function]} - onMouseOut={[Function]} - onMouseOver={[Function]} - onMouseUp={[Function]} - style={ - Object { - "pointerEvents": "all", - } - } - /> - </g> - </svg> - </div> - </div> - </div>, - .c1 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - font-size: 12px; - color: #69707d; - cursor: pointer; - opacity: 1; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.c2 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #6092c0; - border-radius: 100%; -} - -.c5 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #d6bf57; - border-radius: 100%; -} - -.c6 { - width: 11px; - height: 11px; - margin-right: 5.5px; - background: #da8b45; - border-radius: 100%; -} - -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin-left: 80px; - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.c0 > div { - margin-top: 8px; - margin-right: 16px; -} - -.c0 > div:last-child { - margin-right: 0; -} - -.c3 { - white-space: nowrap; - color: #98a2b3; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - -.c4 { - margin-left: 4px; - color: #000000; - display: inline-block; -} - -<styled.div> - <div - className="c0" - > - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#6092c0" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c2" - color="#6092c0" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - Avg. - <styled.span> - <span - className="c4" - > - 468 ms - </span> - </styled.span> - </span> - </styled.span> - </div> - </styled.div> - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#d6bf57" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c5" - color="#d6bf57" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - 95th percentile - </span> - </styled.span> - </div> - </styled.div> - <styled.div - clickable={true} - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <div - className="c1" - disabled={false} - fontSize="12px" - onClick={[Function]} - > - <styled.span - color="#da8b45" - radius={11} - shape="circle" - withMargin={true} - > - <span - className="c6" - color="#da8b45" - radius={11} - shape="circle" - /> - </styled.span> - <styled.span> - <span - className="c3" - > - 99th percentile - </span> - </styled.span> - </div> - </styled.div> - </div> - </styled.div>, -] -`; - -exports[`when response has data when setting hoverX should match snapshots 2`] = ` -Object { - "isDrawing": false, - "selectionEnd": null, - "selectionStart": null, - "seriesEnabledState": Array [], - "showAnnotations": true, -} -`; - -exports[`when response has no data Initially should have correct markup 1`] = ` -Array [ - <div - style={ - Object { - "height": 256, - "position": "relative", - } - } - > - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - > - <g - className="rv-xy-plot__axis rv-xy-plot__axis--horizontal " - style={Object {}} - transform="translate(80,224)" - > - <line - className="rv-xy-plot__axis__line" - style={Object {}} - x1={0} - x2={704} - y1={0} - y2={0} - /> - <g - className="rv-xy-plot__axis__ticks" - transform="translate(0, 0)" - > - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(0, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451606400000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(58.666666666666664, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451606700000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(117.33333333333333, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451607000000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(176, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451607300000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(234.66666666666666, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451607600000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(293.33333333333337, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451607900000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(352, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451608200000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(410.6666666666667, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451608500000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(469.3333333333333, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451608800000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(528, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451609100000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(586.6666666666667, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451609400000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(645.3333333333333, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451609700000 - </text> - </g> - <g - className="rv-xy-plot__axis__tick" - style={Object {}} - transform="translate(704, 0)" - > - <line - className="rv-xy-plot__axis__tick__line" - style={Object {}} - x1={0} - x2={0} - y1={-0} - y2={0} - /> - <text - className="rv-xy-plot__axis__tick__text" - dy="0.72em" - style={Object {}} - textAnchor="middle" - transform="translate(0, 8)" - > - 1451610000000 - </text> - </g> - </g> - </g> - </svg> - <div - style={ - Object { - "left": "50%", - "position": "absolute", - "top": "50%", - "transform": "translate(calc(-50% + 14px),calc(-50% + -16px - 15px))", - } - } - > - No data within this time range. - </div> - </div> - </div> - <div - style={ - Object { - "left": 0, - "pointerEvents": "none", - "position": "absolute", - "top": 0, - } - } - > - <div - className="rv-xy-plot " - style={ - Object { - "height": "256px", - "width": "800px", - } - } - > - <svg - className="rv-xy-plot__inner" - height={256} - onClick={[Function]} - onDoubleClick={[Function]} - onMouseDown={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - onWheel={[Function]} - width={800} - /> - </div> - </div> - </div>, - "", -] -`; - -exports[`when response has no data Initially should have correct state 1`] = ` -Object { - "isDrawing": false, - "selectionEnd": null, - "selectionStart": null, - "seriesEnabledState": Array [], - "showAnnotations": true, -} -`; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json deleted file mode 100644 index e8b96b501af0f9..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "responseTimes": { - "avg": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 480074.48979591834 }, - { "x": 1502282940000, "y": 410277.4358974359 }, - { "x": 1502283000000, "y": 437216.1836734694 }, - { "x": 1502283060000, "y": 478028.36 }, - { "x": 1502283120000, "y": 462688.0816326531 }, - { "x": 1502283180000, "y": 506655.98076923075 }, - { "x": 1502283240000, "y": 585381.5106382979 }, - { "x": 1502283300000, "y": 465090.7073170732 }, - { "x": 1502283360000, "y": 405082.2448979592 }, - { "x": 1502283420000, "y": 480783.9090909091 }, - { "x": 1502283480000, "y": 372316.3953488372 }, - { "x": 1502283540000, "y": 504987.31111111114 }, - { "x": 1502283600000, "y": 395861.23255813954 }, - { "x": 1502283660000, "y": 462582.2291666667 }, - { "x": 1502283720000, "y": 438704.4 }, - { "x": 1502283780000, "y": 441463.5 }, - { "x": 1502283840000, "y": 570707.1774193548 }, - { "x": 1502283900000, "y": 425895.17391304346 }, - { "x": 1502283960000, "y": 438396.2075471698 }, - { "x": 1502284020000, "y": 388522.5333333333 }, - { "x": 1502284080000, "y": 482076.82608695654 }, - { "x": 1502284140000, "y": 471235.04545454547 }, - { "x": 1502284200000, "y": 390323.72 }, - { "x": 1502284260000, "y": 397531.92156862747 }, - { "x": 1502284320000, "y": 447088.89090909093 }, - { "x": 1502284380000, "y": 418634.46774193546 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 440104.2075471698 }, - { "x": 1502284560000, "y": 753710.6212121212 }, - { "x": 1502284620000, "y": 0 } - ], - "p95": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 1215886 }, - { "x": 1502282940000, "y": 1244355.3000000003 }, - { "x": 1502283000000, "y": 1116243.7999999993 }, - { "x": 1502283060000, "y": 1089262.15 }, - { "x": 1502283120000, "y": 1181235.599999999 }, - { "x": 1502283180000, "y": 1066767.5499999998 }, - { "x": 1502283240000, "y": 1568896.2999999996 }, - { "x": 1502283300000, "y": 1012741 }, - { "x": 1502283360000, "y": 1069125.1999999988 }, - { "x": 1502283420000, "y": 1073778.85 }, - { "x": 1502283480000, "y": 1118314.4999999998 }, - { "x": 1502283540000, "y": 1101809.5999999999 }, - { "x": 1502283600000, "y": 1076662.7999999998 }, - { "x": 1502283660000, "y": 990067.35 }, - { "x": 1502283720000, "y": 1557383.999999999 }, - { "x": 1502283780000, "y": 1040584.3500000001 }, - { "x": 1502283840000, "y": 1733451.8499999994 }, - { "x": 1502283900000, "y": 1212304.75 }, - { "x": 1502283960000, "y": 1017966.8 }, - { "x": 1502284020000, "y": 1020771.9999999999 }, - { "x": 1502284080000, "y": 1449191.25 }, - { "x": 1502284140000, "y": 1056132.15 }, - { "x": 1502284200000, "y": 1041506.6499999998 }, - { "x": 1502284260000, "y": 998095.5 }, - { "x": 1502284320000, "y": 1327904 }, - { "x": 1502284380000, "y": 1076961.05 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 1120802.5999999999 }, - { "x": 1502284560000, "y": 2322534 }, - { "x": 1502284620000, "y": 0 } - ], - "p99": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 1494506.1599999988 }, - { "x": 1502282940000, "y": 1549055.6999999993 }, - { "x": 1502283000000, "y": 1539504.0399999986 }, - { "x": 1502283060000, "y": 1392126.2799999996 }, - { "x": 1502283120000, "y": 1601739.799999998 }, - { "x": 1502283180000, "y": 1716968.6400000001 }, - { "x": 1502283240000, "y": 1822798.7799999998 }, - { "x": 1502283300000, "y": 2068320.600000001 }, - { "x": 1502283360000, "y": 2097748.6799999983 }, - { "x": 1502283420000, "y": 1386087.6600000001 }, - { "x": 1502283480000, "y": 1509311.1599999992 }, - { "x": 1502283540000, "y": 1165877.2800000003 }, - { "x": 1502283600000, "y": 1183434.8 }, - { "x": 1502283660000, "y": 1425065.5000000007 }, - { "x": 1502283720000, "y": 1820377.1200000006 }, - { "x": 1502283780000, "y": 1996905.9000000004 }, - { "x": 1502283840000, "y": 2199604.54 }, - { "x": 1502283900000, "y": 1443694.2499999998 }, - { "x": 1502283960000, "y": 1261225.6 }, - { "x": 1502284020000, "y": 1588579.5600000003 }, - { "x": 1502284080000, "y": 2073728.899999998 }, - { "x": 1502284140000, "y": 1330845.0100000002 }, - { "x": 1502284200000, "y": 1160146.2399999998 }, - { "x": 1502284260000, "y": 1623945.5 }, - { "x": 1502284320000, "y": 1390707.1400000001 }, - { "x": 1502284380000, "y": 2067623.4500000002 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 2547299.079999993 }, - { "x": 1502284560000, "y": 4586742.89999998 }, - { "x": 1502284620000, "y": 0 } - ] - }, - "tpmBuckets": [ - { - "key": "2xx", - "dataPoints": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 0 }, - { "x": 1502282940000, "y": 33 }, - { "x": 1502283000000, "y": 42 }, - { "x": 1502283060000, "y": 44 }, - { "x": 1502283120000, "y": 42 }, - { "x": 1502283180000, "y": 47 }, - { "x": 1502283240000, "y": 42 }, - { "x": 1502283300000, "y": 35 }, - { "x": 1502283360000, "y": 44 }, - { "x": 1502283420000, "y": 39 }, - { "x": 1502283480000, "y": 34 }, - { "x": 1502283540000, "y": 38 }, - { "x": 1502283600000, "y": 37 }, - { "x": 1502283660000, "y": 41 }, - { "x": 1502283720000, "y": 37 }, - { "x": 1502283780000, "y": 37 }, - { "x": 1502283840000, "y": 52 }, - { "x": 1502283900000, "y": 38 }, - { "x": 1502283960000, "y": 43 }, - { "x": 1502284020000, "y": 38 }, - { "x": 1502284080000, "y": 41 }, - { "x": 1502284140000, "y": 40 }, - { "x": 1502284200000, "y": 42 }, - { "x": 1502284260000, "y": 40 }, - { "x": 1502284320000, "y": 49 }, - { "x": 1502284380000, "y": 51 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 0 }, - { "x": 1502284560000, "y": 56 }, - { "x": 1502284620000, "y": 0 } - ] - }, - { - "key": "3xx", - "dataPoints": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 0 }, - { "x": 1502282940000, "y": 0 }, - { "x": 1502283000000, "y": 0 }, - { "x": 1502283060000, "y": 0 }, - { "x": 1502283120000, "y": 0 }, - { "x": 1502283180000, "y": 0 }, - { "x": 1502283240000, "y": 0 }, - { "x": 1502283300000, "y": 0 }, - { "x": 1502283360000, "y": 0 }, - { "x": 1502283420000, "y": 0 }, - { "x": 1502283480000, "y": 0 }, - { "x": 1502283540000, "y": 0 }, - { "x": 1502283600000, "y": 0 }, - { "x": 1502283660000, "y": 0 }, - { "x": 1502283720000, "y": 0 }, - { "x": 1502283780000, "y": 0 }, - { "x": 1502283840000, "y": 0 }, - { "x": 1502283900000, "y": 0 }, - { "x": 1502283960000, "y": 0 }, - { "x": 1502284020000, "y": 0 }, - { "x": 1502284080000, "y": 0 }, - { "x": 1502284140000, "y": 0 }, - { "x": 1502284200000, "y": 0 }, - { "x": 1502284260000, "y": 0 }, - { "x": 1502284320000, "y": 0 }, - { "x": 1502284380000, "y": 0 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 0 }, - { "x": 1502284560000, "y": 0 }, - { "x": 1502284620000, "y": 0 } - ] - }, - { - "key": "4xx", - "dataPoints": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 0 }, - { "x": 1502282940000, "y": 1 }, - { "x": 1502283000000, "y": 1 }, - { "x": 1502283060000, "y": 1 }, - { "x": 1502283120000, "y": 3 }, - { "x": 1502283180000, "y": 1 }, - { "x": 1502283240000, "y": 1 }, - { "x": 1502283300000, "y": 1 }, - { "x": 1502283360000, "y": 1 }, - { "x": 1502283420000, "y": 1 }, - { "x": 1502283480000, "y": 3 }, - { "x": 1502283540000, "y": 1 }, - { "x": 1502283600000, "y": 1 }, - { "x": 1502283660000, "y": 1 }, - { "x": 1502283720000, "y": 1 }, - { "x": 1502283780000, "y": 1 }, - { "x": 1502283840000, "y": 2 }, - { "x": 1502283900000, "y": 2 }, - { "x": 1502283960000, "y": 1 }, - { "x": 1502284020000, "y": 1 }, - { "x": 1502284080000, "y": 1 }, - { "x": 1502284140000, "y": 1 }, - { "x": 1502284200000, "y": 2 }, - { "x": 1502284260000, "y": 2 }, - { "x": 1502284320000, "y": 2 }, - { "x": 1502284380000, "y": 3 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 0 }, - { "x": 1502284560000, "y": 2 }, - { "x": 1502284620000, "y": 0 } - ] - }, - { - "key": "5xx", - "dataPoints": [ - { "x": 1502282820000, "y": 0 }, - { "x": 1502282880000, "y": 0 }, - { "x": 1502282940000, "y": 5 }, - { "x": 1502283000000, "y": 6 }, - { "x": 1502283060000, "y": 5 }, - { "x": 1502283120000, "y": 4 }, - { "x": 1502283180000, "y": 4 }, - { "x": 1502283240000, "y": 4 }, - { "x": 1502283300000, "y": 5 }, - { "x": 1502283360000, "y": 4 }, - { "x": 1502283420000, "y": 4 }, - { "x": 1502283480000, "y": 6 }, - { "x": 1502283540000, "y": 6 }, - { "x": 1502283600000, "y": 5 }, - { "x": 1502283660000, "y": 6 }, - { "x": 1502283720000, "y": 7 }, - { "x": 1502283780000, "y": 6 }, - { "x": 1502283840000, "y": 8 }, - { "x": 1502283900000, "y": 6 }, - { "x": 1502283960000, "y": 9 }, - { "x": 1502284020000, "y": 6 }, - { "x": 1502284080000, "y": 4 }, - { "x": 1502284140000, "y": 3 }, - { "x": 1502284200000, "y": 6 }, - { "x": 1502284260000, "y": 9 }, - { "x": 1502284320000, "y": 4 }, - { "x": 1502284380000, "y": 8 }, - { "x": 1502284440000, "y": 0 }, - { "x": 1502284500000, "y": 0 }, - { "x": 1502284560000, "y": 8 }, - { "x": 1502284620000, "y": 0 } - ] - } - ], - "overallAvgDuration": 467582.45401459857, - "noHits": false -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js b/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js deleted file mode 100644 index 7183c4851e9936..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { isEmpty } from 'lodash'; -import { Hint } from 'react-vis'; -import styled from 'styled-components'; -import PropTypes from 'prop-types'; -import { - unit, - units, - px, - borderRadius, - fontSize, - fontSizes, -} from '../../../../style/variables'; -import { Legend } from '../Legend'; -import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; - -const TooltipElm = styled.div` - margin: 0 ${px(unit)}; - transform: translateY(-50%); - border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-radius: ${borderRadius}; - font-size: ${fontSize}; - color: ${({ theme }) => theme.eui.euiColorFullShade}; -`; - -const Header = styled.div` - background: ${({ theme }) => theme.eui.euiColorLightestShade}; - border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; - border-radius: ${borderRadius} ${borderRadius} 0 0; - padding: ${px(units.half)}; - color: ${({ theme }) => theme.eui.euiColorMediumShade}; -`; - -const Content = styled.div` - margin: ${px(units.half)}; - margin-right: ${px(unit)}; - font-size: ${fontSizes.small}; -`; - -const Footer = styled.div` - color: ${({ theme }) => theme.eui.euiColorMediumShade}; - margin: ${px(units.half)}; - font-size: ${fontSizes.small}; -`; - -const LegendContainer = styled.div` - display: flex; - align-items: center; - margin-bottom: ${px(units.quarter)}; - justify-content: space-between; -`; - -const LegendGray = styled(Legend)` - color: ${({ theme }) => theme.eui.euiColorMediumShade}; - padding-bottom: 0; - padding-right: ${px(units.half)}; -`; - -const Value = styled.div` - color: ${({ theme }) => theme.eui.euiColorDarkShade}; - font-size: ${fontSize}; -`; - -export default function Tooltip({ - header, - footer, - tooltipPoints, - x, - y, - ...props -}) { - if (isEmpty(tooltipPoints)) { - return null; - } - - // Only show legend labels if there is more than 1 data set - const showLegends = tooltipPoints.length > 1; - - return ( - <Hint {...props} value={{ x, y }}> - <TooltipElm> - <Header>{header || asAbsoluteDateTime(x, 'seconds')}</Header> - - <Content> - {showLegends ? ( - tooltipPoints.map((point, i) => ( - <LegendContainer key={i}> - <LegendGray - fontSize={fontSize.tiny} - radius={units.half} - color={point.color} - text={point.text} - /> - - <Value>{point.value}</Value> - </LegendContainer> - )) - ) : ( - <Value>{tooltipPoints[0].value}</Value> - )} - </Content> - <Footer>{footer}</Footer> - </TooltipElm> - </Hint> - ); -} - -Tooltip.propTypes = { - header: PropTypes.string, - tooltipPoints: PropTypes.array.isRequired, - x: PropTypes.number, - y: PropTypes.number, -}; - -Tooltip.defaultProps = {}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/get_empty_series.ts similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts rename to x-pack/plugins/apm/public/components/shared/charts/helper/get_empty_series.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/get_timezone_offset_in_ms.test.ts similarity index 95% rename from x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts rename to x-pack/plugins/apm/public/components/shared/charts/helper/get_timezone_offset_in_ms.test.ts index 935895022931c9..f45e207c32c8f9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/get_timezone_offset_in_ms.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getTimezoneOffsetInMs } from './getTimezoneOffsetInMs'; +import { getTimezoneOffsetInMs } from './get_timezone_offset_in_ms'; import moment from 'moment-timezone'; // FAILING: https://github.com/elastic/kibana/issues/50005 diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/get_timezone_offset_in_ms.ts similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts rename to x-pack/plugins/apm/public/components/shared/charts/helper/get_timezone_offset_in_ms.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts index d0301880ef52a0..ca328473db8ccc 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import d3 from 'd3'; -import { getTimezoneOffsetInMs } from '../CustomPlot/getTimezoneOffsetInMs'; +import { getTimezoneOffsetInMs } from './get_timezone_offset_in_ms'; interface Params { domain: [number, number]; diff --git a/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx similarity index 54% rename from x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx index 2f63a77132be98..9a561571df5a7c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx @@ -6,54 +6,18 @@ import { EuiTitle } from '@elastic/eui'; import React from 'react'; import { - asPercent, asDecimal, + asDuration, asInteger, - asDynamicBytes, + asPercent, getFixedByteFormatter, - asDuration, } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { GenericMetricsChart } from '../../../../../server/lib/metrics/transform_metrics_chart'; -// @ts-expect-error -import CustomPlot from '../CustomPlot'; -import { Coordinate } from '../../../../../typings/timeseries'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { useLegacyChartsSync as useChartsSync } from '../../../../hooks/use_charts_sync'; import { Maybe } from '../../../../../typings/common'; - -interface Props { - start: Maybe<number | string>; - end: Maybe<number | string>; - chart: GenericMetricsChart; -} - -export function MetricsChart({ chart }: Props) { - const formatYValue = getYTickFormatter(chart); - const formatTooltip = getTooltipFormatter(chart); - - const transformedSeries = chart.series.map((series) => ({ - ...series, - legendValue: formatYValue(series.overallValue), - })); - - const syncedChartProps = useChartsSync(); - - return ( - <React.Fragment> - <EuiTitle size="xs"> - <span>{chart.title}</span> - </EuiTitle> - <CustomPlot - {...syncedChartProps} - series={transformedSeries} - tickFormatY={formatYValue} - formatTooltipValue={formatTooltip} - yMax={chart.yUnit === 'percent' ? 1 : 'max'} - /> - </React.Fragment> - ); -} +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { TimeseriesChart } from '../timeseries_chart'; function getYTickFormatter(chart: GenericMetricsChart) { switch (chart.yUnit) { @@ -82,24 +46,25 @@ function getYTickFormatter(chart: GenericMetricsChart) { } } -function getTooltipFormatter({ yUnit }: GenericMetricsChart) { - switch (yUnit) { - case 'bytes': { - return (c: Coordinate) => asDynamicBytes(c.y); - } - case 'percent': { - return (c: Coordinate) => asPercent(c.y || 0, 1); - } - case 'time': { - return (c: Coordinate) => asDuration(c.y); - } - case 'integer': { - return (c: Coordinate) => - isValidCoordinateValue(c.y) ? asInteger(c.y) : c.y; - } - default: { - return (c: Coordinate) => - isValidCoordinateValue(c.y) ? asDecimal(c.y) : c.y; - } - } +interface Props { + start: Maybe<number | string>; + end: Maybe<number | string>; + chart: GenericMetricsChart; + fetchStatus: FETCH_STATUS; +} + +export function MetricsChart({ chart, fetchStatus }: Props) { + return ( + <> + <EuiTitle size="xs"> + <span>{chart.title}</span> + </EuiTitle> + <TimeseriesChart + fetchStatus={fetchStatus} + id={chart.key} + timeseries={chart.series} + yLabelFormat={getYTickFormatter(chart) as (y: number) => string} + /> + </> + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index d96f3cd698aed7..6f1f4e01c4d1f6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { ScaleType, Chart, Settings, AreaSeries } from '@elastic/charts'; +import { + ScaleType, + Chart, + Settings, + AreaSeries, + CurveType, +} from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { EuiFlexItem } from '@elastic/eui'; import { EuiFlexGroup } from '@elastic/eui'; @@ -59,6 +65,7 @@ export function SparkPlot(props: Props) { yAccessors={['y']} data={series} color={color} + curve={CurveType.CURVE_MONOTONE_X} /> </Chart> ); diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx index a5d146fcd73ec8..3819ed30d104a0 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; - +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { px, unit } from '../../../../../style/variables'; import { useTheme } from '../../../../../hooks/useTheme'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index b40df89a22c33d..918e940651dee6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -5,8 +5,10 @@ */ import { + AreaSeries, Axis, Chart, + CurveType, LegendItemListener, LineSeries, niceTimeFormatter, @@ -19,14 +21,14 @@ import { import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { TimeSeries } from '../../../../../typings/timeseries'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { useChartsSync } from '../../../../hooks/use_charts_sync'; -import { unit } from '../../../../style/variables'; -import { Annotations } from '../annotations'; -import { ChartContainer } from '../chart_container'; -import { onBrushEnd } from '../helper/helper'; +import { TimeSeries } from '../../../../typings/timeseries'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useChartsSync } from '../../../hooks/use_charts_sync'; +import { unit } from '../../../style/variables'; +import { Annotations } from './annotations'; +import { ChartContainer } from './chart_container'; +import { onBrushEnd } from './helper/helper'; interface Props { id: string; @@ -45,7 +47,7 @@ interface Props { showAnnotations?: boolean; } -export function LineChart({ +export function TimeseriesChart({ id, height = unit * 16, fetchStatus, @@ -127,8 +129,10 @@ export function LineChart({ {showAnnotations && <Annotations />} {timeseries.map((serie) => { + const Series = serie.type === 'area' ? AreaSeries : LineSeries; + return ( - <LineSeries + <Series key={serie.title} id={serie.title} xScaleType={ScaleType.Time} @@ -137,6 +141,7 @@ export function LineChart({ yAccessors={['y']} data={isEmpty ? [] : serie.data} color={serie.color} + curve={CurveType.CURVE_MONOTONE_X} /> ); })} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 2a5948d0ebf0be..41212aa7b982c7 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -26,10 +26,10 @@ import { ChartsSyncContextProvider } from '../../../../context/charts_sync_conte import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ITransactionChartData } from '../../../../selectors/chartSelectors'; +import { ITransactionChartData } from '../../../../selectors/chart_selectors'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { TransactionBreakdown } from '../../TransactionBreakdown'; -import { LineChart } from '../line_chart'; +import { TimeseriesChart } from '../timeseries_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; import { getResponseTimeTickFormatter } from './helper'; import { MLHeader } from './ml_header'; @@ -81,7 +81,7 @@ export function TransactionCharts({ )} </LicenseContext.Consumer> </EuiFlexGroup> - <LineChart + <TimeseriesChart fetchStatus={fetchStatus} id="transactionDuration" timeseries={responseTimeSeries || []} @@ -100,7 +100,7 @@ export function TransactionCharts({ <EuiTitle size="xs"> <span>{tpmLabel(transactionType)}</span> </EuiTitle> - <LineChart + <TimeseriesChart fetchStatus={fetchStatus} id="requestPerMinutes" timeseries={tpmSeries || []} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts similarity index 100% rename from x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts rename to x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index dd9a1e2ec2efe4..b9028ff2e9e8c8 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -13,7 +13,7 @@ import { useFetcher } from '../../../../hooks/useFetcher'; import { useTheme } from '../../../../hooks/useTheme'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { LineChart } from '../line_chart'; +import { TimeseriesChart } from '../timeseries_chart'; function yLabelFormat(y?: number | null) { return asPercent(y || 0, 1); @@ -73,7 +73,7 @@ export function TransactionErrorRateChart({ })} </h2> </EuiTitle> - <LineChart + <TimeseriesChart id="errorRate" height={height} showAnnotations={showAnnotations} diff --git a/x-pack/plugins/apm/public/context/charts_sync_context.tsx b/x-pack/plugins/apm/public/context/charts_sync_context.tsx index 282097fed2460d..d983a857a26ec1 100644 --- a/x-pack/plugins/apm/public/context/charts_sync_context.tsx +++ b/x-pack/plugins/apm/public/context/charts_sync_context.tsx @@ -4,91 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { ReactNode, useMemo, useState } from 'react'; -import { useHistory, useParams } from 'react-router-dom'; -import { fromQuery, toQuery } from '../components/shared/Links/url_helpers'; -import { useFetcher } from '../hooks/useFetcher'; -import { useUrlParams } from '../hooks/useUrlParams'; - -export const LegacyChartsSyncContext = React.createContext<{ - hoverX: number | null; - onHover: (hoverX: number) => void; - onMouseLeave: () => void; - onSelectionEnd: (range: { start: number; end: number }) => void; -} | null>(null); - -export function LegacyChartsSyncContextProvider({ - children, -}: { - children: ReactNode; -}) { - const history = useHistory(); - const [time, setTime] = useState<number | null>(null); - const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - - const { start, end } = urlParams; - const { environment } = uiFilters; - - const { data = { annotations: [] } } = useFetcher( - (callApmApi) => { - if (start && end && serviceName) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', - params: { - path: { - serviceName, - }, - query: { - start, - end, - environment, - }, - }, - }); - } - }, - [start, end, environment, serviceName] - ); - - const value = useMemo(() => { - const hoverXHandlers = { - onHover: (hoverX: number) => { - setTime(hoverX); - }, - onMouseLeave: () => { - setTime(null); - }, - onSelectionEnd: (range: { start: number; end: number }) => { - setTime(null); - - const currentSearch = toQuery(history.location.search); - const nextSearch = { - rangeFrom: new Date(range.start).toISOString(), - rangeTo: new Date(range.end).toISOString(), - }; - - history.push({ - ...history.location, - search: fromQuery({ - ...currentSearch, - ...nextSearch, - }), - }); - }, - hoverX: time, - annotations: data.annotations, - }; - - return { ...hoverXHandlers }; - }, [history, time, data.annotations]); - - return <LegacyChartsSyncContext.Provider value={value} children={children} />; -} - -export const ChartsSyncContext = React.createContext<{ +import React, { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useState, +} from 'react'; + +export const ChartsSyncContext = createContext<{ event: any; - setEvent: Function; + setEvent: Dispatch<SetStateAction<{}>>; } | null>(null); export function ChartsSyncContextProvider({ diff --git a/x-pack/plugins/apm/public/hooks/useTransactionCharts.ts b/x-pack/plugins/apm/public/hooks/useTransactionCharts.ts index 78ea30f466cfa9..c790ac57edc3bf 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionCharts.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionCharts.ts @@ -6,7 +6,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { getTransactionCharts } from '../selectors/chartSelectors'; +import { getTransactionCharts } from '../selectors/chart_selectors'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; diff --git a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx b/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx index 52c7e4c1e3a31a..cde5c84a6097b2 100644 --- a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx +++ b/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx @@ -5,10 +5,7 @@ */ import { useContext } from 'react'; -import { - ChartsSyncContext, - LegacyChartsSyncContext, -} from '../context/charts_sync_context'; +import { ChartsSyncContext } from '../context/charts_sync_context'; export function useChartsSync() { const context = useContext(ChartsSyncContext); @@ -19,13 +16,3 @@ export function useChartsSync() { return context; } - -export function useLegacyChartsSync() { - const context = useContext(LegacyChartsSyncContext); - - if (!context) { - throw new Error('Missing ChartsSync context provider'); - } - - return context; -} diff --git a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts similarity index 97% rename from x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts rename to x-pack/plugins/apm/public/selectors/chart_selectors.test.ts index 901e6052bbf06c..4269ec0e6c0f36 100644 --- a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts @@ -9,16 +9,16 @@ import { getAnomalyScoreSeries, getResponseTimeSeries, getTpmSeries, -} from '../chartSelectors'; +} from './chart_selectors'; import { successColor, warningColor, errorColor, -} from '../../utils/httpStatusCodeToColor'; +} from '../utils/httpStatusCodeToColor'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ApmTimeSeriesResponse } from '../../../server/lib/transactions/charts/get_timeseries_data/transform'; +import { ApmTimeSeriesResponse } from '../../server/lib/transactions/charts/get_timeseries_data/transform'; -describe('chartSelectors', () => { +describe('chart selectors', () => { describe('getAnomalyScoreSeries', () => { it('should return anomalyScoreSeries', () => { const data = [{ x0: 0, x: 10 }]; diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.ts similarity index 98% rename from x-pack/plugins/apm/public/selectors/chartSelectors.ts rename to x-pack/plugins/apm/public/selectors/chart_selectors.ts index 450f02f70c6a42..8330df07c21eb0 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.ts @@ -18,7 +18,7 @@ import { TimeSeries, } from '../../typings/timeseries'; import { IUrlParams } from '../context/UrlParamsContext/types'; -import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries'; +import { getEmptySeries } from '../components/shared/charts/helper/get_empty_series'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; import { asDecimal, asDuration, tpmUnit } from '../../common/utils/formatters'; diff --git a/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts index c1cb903a0bb3ef..0df1cbf0e0eef8 100644 --- a/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts +++ b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts @@ -23,7 +23,6 @@ import { TRANSACTION_RESULT, PROCESSOR_EVENT, } from '../../common/elasticsearch_fieldnames'; -import { stampLogger } from '../shared/stamp-logger'; import { createOrUpdateIndex } from '../shared/create-or-update-index'; import { parseIndexUrl } from '../shared/parse_index_url'; import { ESClient, getEsClient } from '../shared/get_es_client'; @@ -49,8 +48,6 @@ import { ESClient, getEsClient } from '../shared/get_es_client'; // default ones. // - exclude: comma-separated list of fields that should be not be aggregated on. -stampLogger(); - export async function aggregateLatencyMetrics() { const interval = parseInt(String(argv.interval), 10) || 1; const concurrency = parseInt(String(argv.concurrency), 10) || 3; diff --git a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts index 723ff03dc4995b..4739a5b621972c 100644 --- a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts +++ b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts @@ -9,11 +9,8 @@ import { execSync } from 'child_process'; import moment from 'moment'; import path from 'path'; import fs from 'fs'; -import { stampLogger } from '../shared/stamp-logger'; async function run() { - stampLogger(); - const archiveName = 'apm_8.0.0'; // include important APM data and ML data diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index ca47540b04d826..8c64c37d9b7f74 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -15,7 +15,6 @@ import { merge, chunk, flatten, omit } from 'lodash'; import { Client } from '@elastic/elasticsearch'; import { argv } from 'yargs'; import { Logger } from 'kibana/server'; -import { stampLogger } from '../shared/stamp-logger'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { CollectTelemetryParams } from '../../server/lib/apm_telemetry/collect_data_telemetry'; import { downloadTelemetryTemplate } from '../shared/download-telemetry-template'; @@ -25,8 +24,6 @@ import { readKibanaConfig } from '../shared/read-kibana-config'; import { getHttpAuth } from '../shared/get-http-auth'; import { createOrUpdateIndex } from '../shared/create-or-update-index'; -stampLogger(); - async function uploadData() { const githubToken = process.env.GITHUB_TOKEN; diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts index 14245ce1d6c833..bcd6d10d319878 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -54,6 +54,6 @@ export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, }; } catch (e) { - return false; + return { hasData: false, serviceName: undefined }; } } diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md index 002fbfb8b53f78..30011148cd1e7b 100644 --- a/x-pack/plugins/case/README.md +++ b/x-pack/plugins/case/README.md @@ -57,11 +57,12 @@ This action type has no `secrets` properties. #### `subActionParams (addComment)` -| Property | Description | Type | -| -------- | --------------------------------------------------------- | ------ | -| comment | The case’s new comment. | string | -| type | The type of the comment, which can be: `user` or `alert`. | string | - +| Property | Description | Type | +| -------- | ----------------------------------------------------------------------- | ----------------- | +| type | The type of the comment | `user` \| `alert` | +| comment | The comment. Valid only when type is `user`. | string | +| alertId | The alert ID. Valid only when the type is `alert` | string | +| index | The index where the alert is saved. Valid only when the type is `alert` | string | #### `connector` | Property | Description | Type | diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index b4daac93940d88..920858a1e39b4a 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -8,24 +8,33 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; -const CommentBasicRt = rt.type({ +export const CommentAttributesBasicRt = rt.type({ + created_at: rt.string, + created_by: UserRT, + pushed_at: rt.union([rt.string, rt.null]), + pushed_by: rt.union([UserRT, rt.null]), + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), +}); + +export const ContextTypeUserRt = rt.type({ comment: rt.string, - type: rt.union([rt.literal('alert'), rt.literal('user')]), + type: rt.literal('user'), }); -export const CommentAttributesRt = rt.intersection([ - CommentBasicRt, - rt.type({ - created_at: rt.string, - created_by: UserRT, - pushed_at: rt.union([rt.string, rt.null]), - pushed_by: rt.union([UserRT, rt.null]), - updated_at: rt.union([rt.string, rt.null]), - updated_by: rt.union([UserRT, rt.null]), - }), -]); +export const ContextTypeAlertRt = rt.type({ + type: rt.literal('alert'), + alertId: rt.string, + index: rt.string, +}); -export const CommentRequestRt = CommentBasicRt; +const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); + +const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); + +export const CommentRequestRt = ContextBasicRt; export const CommentResponseRt = rt.intersection([ CommentAttributesRt, @@ -38,10 +47,25 @@ export const CommentResponseRt = rt.intersection([ export const AllCommentsResponseRT = rt.array(CommentResponseRt); export const CommentPatchRequestRt = rt.intersection([ - rt.partial(CommentBasicRt.props), + /** + * Partial updates are not allowed. + * We want to prevent the user for changing the type without removing invalid fields. + */ + ContextBasicRt, rt.type({ id: rt.string, version: rt.string }), ]); +/** + * This type is used by the CaseService. + * Because the type for the attributes of savedObjectClient update function is Partial<T> + * we need to make all of our attributes partial too. + * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. + */ +export const CommentPatchAttributesRt = rt.intersection([ + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.partial(CommentAttributesBasicRt.props), +]); + export const CommentsResponseRt = rt.type({ comments: rt.array(CommentResponseRt), page: rt.number, @@ -62,3 +86,6 @@ export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>; export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>; export type CommentsResponse = rt.TypeOf<typeof CommentsResponseRt>; export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>; +export type CommentPatchAttributes = rt.TypeOf<typeof CommentPatchAttributesRt>; +export type CommentRequestUserType = rt.TypeOf<typeof ContextTypeUserRt>; +export type CommentRequestAlertType = rt.TypeOf<typeof ContextTypeAlertRt>; diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 50e104b30178ab..d00df5a3246bd2 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import { CommentType } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -31,7 +32,10 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, + comment: { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, + }, }); expect(res.id).toEqual('mock-id-1'); @@ -54,6 +58,43 @@ describe('addComment', () => { }); }); + test('it adds a comment of type alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const res = await caseClient.client.addComment({ + caseId: 'mock-id-1', + comment: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }); + + expect(res.id).toEqual('mock-id-1'); + expect(res.totalComment).toEqual(res.comments!.length); + expect(res.comments![res.comments!.length - 1]).toEqual({ + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + created_at: '2020-10-23T21:54:48.952Z', + created_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + id: 'mock-comment', + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + version: 'WzksMV0=', + }); + }); + test('it updates the case correctly after adding a comment', async () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, @@ -63,7 +104,10 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, + comment: { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, + }, }); expect(res.updated_at).toEqual('2020-10-23T21:54:48.952Z'); @@ -83,7 +127,10 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, + comment: { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, + }, }); expect( @@ -99,7 +146,7 @@ describe('addComment', () => { username: 'awesome', }, action_field: ['comment'], - new_value: 'Wow, good luck catching that bad meanie!', + new_value: '{"comment":"Wow, good luck catching that bad meanie!","type":"user"}', old_value: null, }, references: [ @@ -127,7 +174,10 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, + comment: { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, + }, }); expect(res.id).toEqual('mock-id-1'); @@ -151,7 +201,7 @@ describe('addComment', () => { }); describe('unhappy path', () => { - test('it throws when missing comment', async () => { + test('it throws when missing type', async () => { expect.assertions(3); const savedObjectsClient = createMockSavedObjectsRepository({ @@ -172,25 +222,126 @@ describe('addComment', () => { }); }); - test('it throws when missing comment type', async () => { + test('it throws when missing attributes: type user', async () => { expect.assertions(3); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client - .addComment({ - caseId: 'mock-id-1', - // @ts-expect-error - comment: { comment: 'a comment' }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + const allRequestAttributes = { + type: CommentType.user, + comment: 'a comment', + }; + + ['comment'].forEach((attribute) => { + const requestAttributes = omit(attribute, allRequestAttributes); + caseClient.client + .addComment({ + caseId: 'mock-id-1', + // @ts-expect-error + comment: { + ...requestAttributes, + }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); + }); + + test('it throws when excess attributes are provided: type user', async () => { + expect.assertions(6); + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + + ['alertId', 'index'].forEach((attribute) => { + caseClient.client + .addComment({ + caseId: 'mock-id-1', + comment: { + [attribute]: attribute, + comment: 'a comment', + type: CommentType.user, + }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); + }); + + test('it throws when missing attributes: type alert', async () => { + expect.assertions(6); + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }; + + ['alertId', 'index'].forEach((attribute) => { + const requestAttributes = omit(attribute, allRequestAttributes); + caseClient.client + .addComment({ + caseId: 'mock-id-1', + // @ts-expect-error + comment: { + ...requestAttributes, + }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); + }); + + test('it throws when excess attributes are provided: type alert', async () => { + expect.assertions(3); + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + + ['comment'].forEach((attribute) => { + caseClient.client + .addComment({ + caseId: 'mock-id-1', + comment: { + [attribute]: attribute, + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); }); test('it throws when the case does not exists', async () => { @@ -204,7 +355,10 @@ describe('addComment', () => { caseClient.client .addComment({ caseId: 'not-exists', - comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, + comment: { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, + }, }) .catch((e) => { expect(e).not.toBeNull(); @@ -224,7 +378,10 @@ describe('addComment', () => { caseClient.client .addComment({ caseId: 'mock-id-1', - comment: { comment: 'Throw an error', type: CommentType.user }, + comment: { + comment: 'Throw an error', + type: CommentType.user, + }, }) .catch((e) => { expect(e).not.toBeNull(); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index a95b7833a5232f..169157c95d4c1a 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -9,15 +9,9 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; +import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; -import { - throwErrors, - excess, - CaseResponseRt, - CommentRequestRt, - CaseResponse, -} from '../../../common/api'; +import { throwErrors, CaseResponseRt, CommentRequestRt, CaseResponse } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; @@ -33,10 +27,13 @@ export const addComment = ({ comment, }: CaseClientAddComment): Promise<CaseResponse> => { const query = pipe( - excess(CommentRequestRt).decode(comment), + // TODO: Excess CommentRequestRt when the excess() function supports union types + CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); + decodeComment(comment); + const myCase = await caseService.getCase({ client: savedObjectsClient, caseId, @@ -105,7 +102,7 @@ export const addComment = ({ caseId: myCase.id, commentId: newComment.id, fields: ['comment'], - newValue: query.comment, + newValue: JSON.stringify(query), }), ], }), diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index e14281e047915a..90bb1d604e7330 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; @@ -614,12 +615,31 @@ describe('case connector', () => { }); describe('add comment', () => { - it('succeeds when params is valid', () => { + it('succeeds when type is user', () => { + const params: Record<string, unknown> = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { + comment: 'a comment', + type: CommentType.user, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual(params); + }); + + it('succeeds when type is an alert', () => { const params: Record<string, unknown> = { subAction: 'addComment', subActionParams: { caseId: 'case-id', - comment: { comment: 'a comment', type: CommentType.user }, + comment: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, }, }; @@ -635,6 +655,89 @@ describe('case connector', () => { validateParams(caseActionType, params); }).toThrow(); }); + + it('fails when missing attributes: type user', () => { + const allParams = { + type: CommentType.user, + comment: 'a comment', + }; + + ['comment'].forEach((attribute) => { + const comment = omit(attribute, allParams); + const params: Record<string, unknown> = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); + + it('fails when missing attributes: type alert', () => { + const allParams = { + type: CommentType.alert, + comment: 'a comment', + alertId: 'test-id', + index: 'test-index', + }; + + ['alertId', 'index'].forEach((attribute) => { + const comment = omit(attribute, allParams); + const params: Record<string, unknown> = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); + + it('fails when excess attributes are provided: type user', () => { + ['alertId', 'index'].forEach((attribute) => { + const params: Record<string, unknown> = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + [attribute]: attribute, + type: CommentType.user, + comment: 'a comment', + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); + + it('fails when excess attributes are provided: type alert', () => { + ['comment'].forEach((attribute) => { + const params: Record<string, unknown> = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + [attribute]: attribute, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); }); }); @@ -866,7 +969,10 @@ describe('case connector', () => { subAction: 'addComment', subActionParams: { caseId: 'case-id', - comment: { comment: 'a comment', type: CommentType.user }, + comment: { + comment: 'a comment', + type: CommentType.user, + }, }, }; @@ -883,7 +989,10 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ caseId: 'case-id', - comment: { comment: 'a comment', type: CommentType.user }, + comment: { + comment: 'a comment', + type: CommentType.user, + }, }); }); }); diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index aa503e96be30d3..039c0e2e7e67f1 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -9,10 +9,18 @@ import { validateConnector } from './validators'; // Reserved for future implementation export const CaseConfigurationSchema = schema.object({}); -const CommentProps = { +const ContextTypeUserSchema = schema.object({ + type: schema.literal('user'), comment: schema.string(), - type: schema.oneOf([schema.literal('alert'), schema.literal('user')]), -}; +}); + +const ContextTypeAlertSchema = schema.object({ + type: schema.literal('alert'), + alertId: schema.string(), + index: schema.string(), +}); + +export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), @@ -86,7 +94,7 @@ const CaseUpdateRequestProps = { const CaseAddCommentRequestProps = { caseId: schema.string(), - comment: schema.object(CommentProps), + comment: CommentSchema, }; export const ExecutorSubActionCreateParamsSchema = schema.object(CaseBasicProps); diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index b3a05163fa6f4d..da15f64a5718f5 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -13,11 +13,13 @@ import { CaseConfigurationSchema, ExecutorSubActionAddCommentParamsSchema, ConnectorSchema, + CommentSchema, } from './schema'; import { CaseResponse, CasesResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>; export type Connector = TypeOf<typeof ConnectorSchema>; +export type Comment = TypeOf<typeof CommentSchema>; export type ExecutorSubActionCreateParams = TypeOf<typeof ExecutorSubActionCreateParamsSchema>; export type ExecutorSubActionUpdateParams = TypeOf<typeof ExecutorSubActionUpdateParamsSchema>; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 9314ebb445820f..4c0b5887ca9988 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -297,6 +297,38 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [ updated_at: '2019-11-25T22:32:30.608Z', version: 'WzYsMV0=', }, + { + type: 'cases-comment', + id: 'mock-comment-4', + attributes: { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + created_at: '2019-11-25T22:32:30.608Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T22:32:30.608Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-4', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, ]; export const mockCaseConfigure: Array<SavedObject<ESCasesConfigureAttributes>> = [ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 400e8ca404ca5e..5cb411f17a7448 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import { omit } from 'lodash/fp'; import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; @@ -15,12 +17,14 @@ import { } from '../../__fixtures__'; import { initPatchCommentApi } from './patch_comment'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CommentType } from '../../../../../common/api'; describe('PATCH comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { routeHandler = await createRoute(initPatchCommentApi, 'patch'); }); + it(`Patch a comment`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, @@ -29,6 +33,7 @@ describe('PATCH comment', () => { case_id: 'mock-id-1', }, body: { + type: CommentType.user, comment: 'Update my comment', id: 'mock-comment-1', version: 'WzEsMV0=', @@ -49,6 +54,183 @@ describe('PATCH comment', () => { ); }); + it(`Patch an alert`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'patch', + params: { + case_id: 'mock-id-4', + }, + body: { + type: CommentType.alert, + alertId: 'new-id', + index: 'test-index', + id: 'mock-comment-4', + version: 'WzYsMV0=', + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( + 'new-id' + ); + }); + + it(`it throws when missing attributes: type user`, async () => { + const allRequestAttributes = { + type: CommentType.user, + comment: 'a comment', + }; + + for (const attribute of ['comment']) { + const requestAttributes = omit(attribute, allRequestAttributes); + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: requestAttributes, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when excess attributes are provided: type user`, async () => { + for (const attribute of ['alertId', 'index']) { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: { + [attribute]: attribute, + comment: 'a comment', + type: CommentType.user, + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when missing attributes: type alert`, async () => { + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: requestAttributes, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when excess attributes are provided: type alert`, async () => { + for (const attribute of ['comment']) { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: { + [attribute]: attribute, + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it fails to change the type of the comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'patch', + params: { + case_id: 'mock-id-1', + }, + body: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + id: 'mock-comment-1', + version: 'WzEsMV0=', + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + expect(response.payload.message).toEqual('You cannot change the type of the comment.'); + }); + it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, @@ -57,6 +239,7 @@ describe('PATCH comment', () => { case_id: 'mock-id-1', }, body: { + type: CommentType.user, id: 'mock-comment-1', comment: 'Update my comment', version: 'badv=', @@ -73,6 +256,7 @@ describe('PATCH comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); + it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, @@ -81,6 +265,7 @@ describe('PATCH comment', () => { case_id: 'mock-id-1', }, body: { + type: CommentType.user, comment: 'Update my comment', id: 'mock-comment-does-not-exist', version: 'WzEsMV0=', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index e75e89fa207b91..82fe3fce67653c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; +import { pick } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; import { CommentPatchRequestRt, CaseResponseRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils'; +import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; export function initPatchCommentApi({ @@ -42,6 +43,9 @@ export function initPatchCommentApi({ fold(throwErrors(Boom.badRequest), identity) ); + const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; + decodeComment(queryRestAttributes); + const myCase = await caseService.getCase({ client, caseId, @@ -49,19 +53,23 @@ export function initPatchCommentApi({ const myComment = await caseService.getComment({ client, - commentId: query.id, + commentId: queryCommentId, }); if (myComment == null) { - throw Boom.notFound(`This comment ${query.id} does not exist anymore.`); + throw Boom.notFound(`This comment ${queryCommentId} does not exist anymore.`); + } + + if (myComment.attributes.type !== queryRestAttributes.type) { + throw Boom.badRequest(`You cannot change the type of the comment.`); } const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); if (caseRef == null || (caseRef != null && caseRef.id !== caseId)) { - throw Boom.notFound(`This comment ${query.id} does not exist in ${caseId}).`); + throw Boom.notFound(`This comment ${queryCommentId} does not exist in ${caseId}).`); } - if (query.version !== myComment.version) { + if (queryCommentVersion !== myComment.version) { throw Boom.conflict( 'This case has been updated. Please refresh before saving additional updates.' ); @@ -73,13 +81,13 @@ export function initPatchCommentApi({ const [updatedComment, updatedCase] = await Promise.all([ caseService.patchComment({ client, - commentId: query.id, + commentId: queryCommentId, updatedAttributes: { - comment: query.comment, + ...queryRestAttributes, updated_at: updatedDate, updated_by: { email, full_name, username }, }, - version: query.version, + version: queryCommentVersion, }), caseService.patchCase({ client, @@ -122,8 +130,12 @@ export function initPatchCommentApi({ caseId: request.params.case_id, commentId: updatedComment.id, fields: ['comment'], - newValue: query.comment, - oldValue: myComment.attributes.comment, + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), }), ], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 0b733bb034f8cc..2909aa40a44252 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; @@ -55,6 +56,174 @@ describe('POST comment', () => { ); }); + it(`Posts a new comment of type alert`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( + 'mock-comment' + ); + }); + + it(`it throws when missing type`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: {}, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + + it(`it throws when missing attributes: type user`, async () => { + const allRequestAttributes = { + type: CommentType.user, + comment: 'a comment', + }; + + for (const attribute of ['comment']) { + const requestAttributes = omit(attribute, allRequestAttributes); + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: requestAttributes, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when excess attributes are provided: type user`, async () => { + for (const attribute of ['alertId', 'index']) { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: { + [attribute]: attribute, + comment: 'a comment', + type: CommentType.user, + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when missing attributes: type alert`, async () => { + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: requestAttributes, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + + it(`it throws when excess attributes are provided: type alert`, async () => { + for (const attribute of ['comment']) { + const request = httpServerMock.createKibanaRequest({ + path: CASE_COMMENTS_URL, + method: 'post', + params: { + case_id: 'mock-id-1', + }, + body: { + [attribute]: attribute, + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }, + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + } + }); + it(`Returns an error if the case does not exist`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index 01de9abac16afe..6e2dfdc59f1b1b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -104,7 +104,7 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments).toHaveLength(3); + expect(response.payload.comments).toHaveLength(4); }); it(`returns an error when thrown from getAllCaseComments`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 80b65b54468fcb..6ba2da111090f7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -11,7 +11,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; +import { + flattenCaseSavedObject, + wrapError, + escapeHatch, + getCommentContextFromAttributes, +} from '../utils'; import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; @@ -164,6 +169,7 @@ export function initPushCaseUserActionApi({ ], }), ]); + return response.ok({ body: CaseResponseRt.encode( flattenCaseSavedObject({ @@ -183,6 +189,7 @@ export function initPushCaseUserActionApi({ attributes: { ...origComment.attributes, ...updatedComment?.attributes, + ...getCommentContextFromAttributes(origComment.attributes), }, version: updatedComment?.version ?? origComment.version, references: origComment?.references ?? [], diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index fc1086b03814b6..a67bae5ed74dc9 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -117,7 +117,7 @@ describe('Utils', () => { it('transforms correctly', () => { const comment = { comment: 'A comment', - type: CommentType.user, + type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -140,7 +140,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const comment = { comment: 'A comment', - type: CommentType.user, + type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', }; @@ -161,7 +161,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const comment = { comment: 'A comment', - type: CommentType.user, + type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index f8fe149c2ff2f6..589d7c02a7be60 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { badRequest, boomify, isBoom } from '@hapi/boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; import { schema } from '@kbn/config-schema'; -import { boomify, isBoom } from '@hapi/boom'; import { CustomHttpResponseOptions, ResponseError, @@ -23,6 +26,13 @@ import { ESCaseConnector, ESCaseAttributes, CommentRequest, + ContextTypeUserRt, + ContextTypeAlertRt, + CommentRequestUserType, + CommentRequestAlertType, + CommentType, + excess, + throwErrors, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -56,24 +66,22 @@ export const transformNewCase = ({ updated_by: null, }); -interface NewCommentArgs extends CommentRequest { +type NewCommentArgs = CommentRequest & { createdDate: string; email?: string | null; full_name?: string | null; username?: string | null; -} +}; export const transformNewComment = ({ - comment, - type, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention full_name, username, + ...comment }: NewCommentArgs): CommentAttributes => ({ - comment, - type, + ...comment, created_at: createdDate, created_by: { email, full_name, username }, pushed_at: null, @@ -178,3 +186,33 @@ export const sortToSnake = (sortField: string): SortFieldCase => { }; export const escapeHatch = schema.object({}, { unknowns: 'allow' }); + +const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { + return context.type === CommentType.user; +}; + +const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { + return context.type === CommentType.alert; +}; + +export const decodeComment = (comment: CommentRequest) => { + if (isUserContext(comment)) { + pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isAlertContext(comment)) { + pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); + } +}; + +export const getCommentContextFromAttributes = ( + attributes: CommentAttributes +): CommentRequestUserType | CommentRequestAlertType => + isUserContext(attributes) + ? { + type: CommentType.user, + comment: attributes.comment, + } + : { + type: CommentType.alert, + alertId: attributes.alertId, + index: attributes.index, + }; diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 87478eb23641f3..8f398c63e01bd5 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -21,6 +21,12 @@ export const caseCommentSavedObjectType: SavedObjectsType = { type: { type: 'keyword', }, + alertId: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, created_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index cab8cb499c3fae..0ce2b196af471e 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -23,6 +23,7 @@ import { CommentAttributes, SavedObjectFindOptions, User, + CommentPatchAttributes, } from '../../common/api'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; @@ -78,18 +79,15 @@ type PatchCaseArgs = PatchCase & ClientArgs; interface PatchCasesArgs extends ClientArgs { cases: PatchCase[]; } -interface UpdateCommentArgs extends ClientArgs { - commentId: string; - updatedAttributes: Partial<CommentAttributes>; - version?: string; -} interface PatchComment { commentId: string; - updatedAttributes: Partial<CommentAttributes>; + updatedAttributes: CommentPatchAttributes; version?: string; } +type UpdateCommentArgs = PatchComment & ClientArgs; + interface PatchComments extends ClientArgs { comments: PatchComment[]; } 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 bd57958b0cb88a..c1f60f2d630490 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 @@ -9,6 +9,7 @@ import { IClusterClientAdapter } from './cluster_client_adapter'; const createClusterClientMock = () => { const mock: jest.Mocked<IClusterClientAdapter> = { indexDocument: jest.fn(), + indexDocuments: jest.fn(), doesIlmPolicyExist: jest.fn(), createIlmPolicy: jest.fn(), doesIndexTemplateExist: jest.fn(), @@ -16,6 +17,7 @@ const createClusterClientMock = () => { doesAliasExist: jest.fn(), createIndex: jest.fn(), queryEventsBySavedObject: 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 6e787c905d4001..57a6b1d3bb932a 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyClusterClient, Logger } from 'src/core/server'; +import { LegacyClusterClient } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; -import { ClusterClientAdapter, IClusterClientAdapter } from './cluster_client_adapter'; +import { + ClusterClientAdapter, + IClusterClientAdapter, + EVENT_BUFFER_LENGTH, +} from './cluster_client_adapter'; +import { contextMock } from './context.mock'; import { findOptionsSchema } from '../event_log_client'; +import { delay } from '../lib/delay'; +import { times } from 'lodash'; type EsClusterClient = Pick<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>; +type MockedLogger = ReturnType<typeof loggingSystemMock['createLogger']>; -let logger: Logger; +let logger: MockedLogger; let clusterClient: EsClusterClient; let clusterClientAdapter: IClusterClientAdapter; @@ -21,22 +29,130 @@ beforeEach(() => { clusterClientAdapter = new ClusterClientAdapter({ logger, clusterClientPromise: Promise.resolve(clusterClient), + context: contextMock.create(), }); }); describe('indexDocument', () => { - test('should call cluster client with given doc', async () => { - await clusterClientAdapter.indexDocument({ args: true }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { - args: true, + test('should call cluster client bulk with given doc', async () => { + clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); + + await retryUntil('cluster client bulk called', () => { + return clusterClient.callAsInternalUser.mock.calls.length !== 0; + }); + + expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + body: [{ create: { _index: 'event-log' } }, { message: 'foo' }], }); }); - test('should throw error when cluster client throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); - await expect( - clusterClientAdapter.indexDocument({ args: true }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); + test('should log an error when cluster client throws an error', async () => { + clusterClient.callAsInternalUser.mockRejectedValue(new Error('expected failure')); + clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); + await retryUntil('cluster client bulk called', () => { + return logger.error.mock.calls.length !== 0; + }); + + const expectedMessage = `error writing bulk events: "expected failure"; docs: [{"create":{"_index":"event-log"}},{"message":"foo"}]`; + expect(logger.error).toHaveBeenCalledWith(expectedMessage); + }); +}); + +describe('shutdown()', () => { + test('should work if no docs have been written', async () => { + const result = await clusterClientAdapter.shutdown(); + expect(result).toBeFalsy(); + }); + + test('should work if some docs have been written', async () => { + clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); + const resultPromise = clusterClientAdapter.shutdown(); + + await retryUntil('cluster client bulk called', () => { + return clusterClient.callAsInternalUser.mock.calls.length !== 0; + }); + + const result = await resultPromise; + expect(result).toBeFalsy(); + }); +}); + +describe('buffering documents', () => { + test('should write buffered docs after timeout', async () => { + // write EVENT_BUFFER_LENGTH - 1 docs + for (let i = 0; i < EVENT_BUFFER_LENGTH - 1; i++) { + clusterClientAdapter.indexDocument({ body: { message: `foo ${i}` }, index: 'event-log' }); + } + + await retryUntil('cluster client bulk called', () => { + return clusterClient.callAsInternalUser.mock.calls.length !== 0; + }); + + const expectedBody = []; + for (let i = 0; i < EVENT_BUFFER_LENGTH - 1; i++) { + expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); + } + + expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + body: expectedBody, + }); + }); + + test('should write buffered docs after buffer exceeded', async () => { + // write EVENT_BUFFER_LENGTH + 1 docs + for (let i = 0; i < EVENT_BUFFER_LENGTH + 1; i++) { + clusterClientAdapter.indexDocument({ body: { message: `foo ${i}` }, index: 'event-log' }); + } + + await retryUntil('cluster client bulk called', () => { + return clusterClient.callAsInternalUser.mock.calls.length >= 2; + }); + + const expectedBody = []; + for (let i = 0; i < EVENT_BUFFER_LENGTH; i++) { + expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); + } + + expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(1, 'bulk', { + body: expectedBody, + }); + + expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(2, 'bulk', { + body: [{ create: { _index: 'event-log' } }, { message: `foo 100` }], + }); + }); + + test('should handle lots of docs correctly with a delay in the bulk index', async () => { + // @ts-ignore + clusterClient.callAsInternalUser.mockImplementation = async () => await delay(100); + + const docs = times(EVENT_BUFFER_LENGTH * 10, (i) => ({ + body: { message: `foo ${i}` }, + index: 'event-log', + })); + + // write EVENT_BUFFER_LENGTH * 10 docs + for (const doc of docs) { + clusterClientAdapter.indexDocument(doc); + } + + await retryUntil('cluster client bulk called', () => { + return clusterClient.callAsInternalUser.mock.calls.length >= 10; + }); + + for (let i = 0; i < 10; i++) { + const expectedBody = []; + for (let j = 0; j < EVENT_BUFFER_LENGTH; j++) { + expectedBody.push( + { create: { _index: 'event-log' } }, + { message: `foo ${i * EVENT_BUFFER_LENGTH + j}` } + ); + } + + expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(i + 1, 'bulk', { + body: expectedBody, + }); + } }); }); @@ -575,3 +691,29 @@ describe('queryEventsBySavedObject', () => { `); }); }); + +type RetryableFunction = () => boolean; + +const RETRY_UNTIL_DEFAULT_COUNT = 20; +const RETRY_UNTIL_DEFAULT_WAIT = 1000; // milliseconds + +async function retryUntil( + label: string, + fn: RetryableFunction, + count: number = RETRY_UNTIL_DEFAULT_COUNT, + wait: number = RETRY_UNTIL_DEFAULT_WAIT +): Promise<boolean> { + while (count > 0) { + count--; + + if (fn()) return true; + + // eslint-disable-next-line no-console + console.log(`attempt failed waiting for "${label}", attempts left: ${count}`); + + if (count === 0) return false; + await delay(wait); + } + + return false; +} 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 fa9f9c36052a10..d1dcf621150a6d 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 @@ -4,20 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Subject } from 'rxjs'; +import { bufferTime, filter, switchMap } from 'rxjs/operators'; import { reject, isUndefined } from 'lodash'; import { SearchResponse, Client } from 'elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, LegacyClusterClient } from 'src/core/server'; - -import { IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; +import { EsContext } from '.'; +import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; +export const EVENT_BUFFER_TIME = 1000; // milliseconds +export const EVENT_BUFFER_LENGTH = 100; + export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; export type IClusterClientAdapter = PublicMethodsOf<ClusterClientAdapter>; +export interface Doc { + index: string; + body: IEvent; +} + export interface ConstructorOpts { logger: Logger; clusterClientPromise: Promise<EsClusterClient>; + context: EsContext; } export interface QueryEventsBySavedObjectResult { @@ -30,14 +41,67 @@ export interface QueryEventsBySavedObjectResult { export class ClusterClientAdapter { private readonly logger: Logger; private readonly clusterClientPromise: Promise<EsClusterClient>; + private readonly docBuffer$: Subject<Doc>; + private readonly context: EsContext; + private readonly docsBufferedFlushed: Promise<void>; constructor(opts: ConstructorOpts) { this.logger = opts.logger; this.clusterClientPromise = opts.clusterClientPromise; + this.context = opts.context; + this.docBuffer$ = new Subject<Doc>(); + + // buffer event log docs for time / buffer length, ignore empty + // buffers, then index the buffered docs; kick things off with a + // promise on the observable, which we'll wait on in shutdown + this.docsBufferedFlushed = this.docBuffer$ + .pipe( + bufferTime(EVENT_BUFFER_TIME, null, EVENT_BUFFER_LENGTH), + filter((docs) => docs.length > 0), + switchMap(async (docs) => await this.indexDocuments(docs)) + ) + .toPromise(); } - public async indexDocument(doc: unknown): Promise<void> { - await this.callEs<ReturnType<Client['index']>>('index', doc); + // This will be called at plugin stop() time; the assumption is any plugins + // depending on the event_log will already be stopped, and so will not be + // writing more event docs. We complete the docBuffer$ observable, + // and wait for the docsBufffered$ observable to complete via it's promise, + // and so should end up writing all events out that pass through, before + // Kibana shuts down (cleanly). + public async shutdown(): Promise<void> { + this.docBuffer$.complete(); + await this.docsBufferedFlushed; + } + + public indexDocument(doc: Doc): void { + this.docBuffer$.next(doc); + } + + async indexDocuments(docs: Doc[]): Promise<void> { + // If es initialization failed, don't try to index. + // Also, don't log here, we log the failure case in plugin startup + // instead, otherwise we'd be spamming the log (if done here) + if (!(await this.context.waitTillReady())) { + return; + } + + const bulkBody: Array<Record<string, unknown>> = []; + + for (const doc of docs) { + if (doc.body === undefined) continue; + + bulkBody.push({ create: { _index: doc.index } }); + bulkBody.push(doc.body); + } + + try { + await this.callEs<ReturnType<Client['bulk']>>('bulk', { body: bulkBody }); + } catch (err) { + this.logger.error( + `error writing bulk events: "${err.message}"; docs: ${JSON.stringify(bulkBody)}` + ); + } } public async doesIlmPolicyExist(policyName: string): Promise<boolean> { diff --git a/x-pack/plugins/event_log/server/es/context.mock.ts b/x-pack/plugins/event_log/server/es/context.mock.ts index aac7c684218aa8..49a57fcb2b00d2 100644 --- a/x-pack/plugins/event_log/server/es/context.mock.ts +++ b/x-pack/plugins/event_log/server/es/context.mock.ts @@ -18,6 +18,7 @@ const createContextMock = () => { logger: loggingSystemMock.createLogger(), esNames: namesMock.create(), initialize: jest.fn(), + shutdown: jest.fn(), waitTillReady: jest.fn(async () => true), esAdapter: clusterClientAdapterMock.create(), initialized: true, diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index 8c967e68299b55..d7f67620e7968d 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -18,6 +18,7 @@ export interface EsContext { esNames: EsNames; esAdapter: IClusterClientAdapter; initialize(): void; + shutdown(): Promise<void>; waitTillReady(): Promise<boolean>; initialized: boolean; } @@ -52,6 +53,7 @@ class EsContextImpl implements EsContext { this.esAdapter = new ClusterClientAdapter({ logger: params.logger, clusterClientPromise: params.clusterClientPromise, + context: this, }); } @@ -74,6 +76,10 @@ class EsContextImpl implements EsContext { }); } + async shutdown() { + await this.esAdapter.shutdown(); + } + // waits till the ES initialization is done, returns true if it was successful, // false if it was not successful async waitTillReady(): Promise<boolean> { diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index ea699af45ccd2f..28b4f5325dcb78 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -59,7 +59,8 @@ describe('EventLogger', () => { eventLogger.logEvent({}); await waitForLogEvent(systemLogger); delay(WRITE_LOG_WAIT_MILLIS); // sleep a bit longer since event logging is async - expect(esContext.esAdapter.indexDocument).not.toHaveBeenCalled(); + expect(esContext.esAdapter.indexDocument).toHaveBeenCalled(); + expect(esContext.esAdapter.indexDocuments).not.toHaveBeenCalled(); }); test('method logEvent() writes expected default values', async () => { diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 658d90d8096525..db24379bb46ba7 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -20,14 +20,10 @@ import { EventSchema, } from './types'; import { SAVED_OBJECT_REL_PRIMARY } from './types'; +import { Doc } from './es/cluster_client_adapter'; type SystemLogger = Plugin['systemLogger']; -interface Doc { - index: string; - body: IEvent; -} - interface IEventLoggerCtorParams { esContext: EsContext; eventLogService: EventLogService; @@ -159,44 +155,9 @@ function validateEvent(eventLogService: IEventLogService, event: IEvent): IValid export const EVENT_LOGGED_PREFIX = `event logged: `; function logEventDoc(logger: Logger, doc: Doc): void { - setImmediate(() => { - logger.info(`${EVENT_LOGGED_PREFIX}${JSON.stringify(doc.body)}`); - }); + logger.info(`event logged: ${JSON.stringify(doc.body)}`); } function indexEventDoc(esContext: EsContext, doc: Doc): void { - // TODO: - // the setImmediate() on an async function is a little overkill, but, - // setImmediate() may be tweakable via node params, whereas async - // tweaking is in the v8 params realm, which is very dicey. - // Long-term, we should probably create an in-memory queue for this, so - // we can explictly see/set the queue lengths. - - // already verified this.clusterClient isn't null above - setImmediate(async () => { - try { - await indexLogEventDoc(esContext, doc); - } catch (err) { - esContext.logger.warn(`error writing event doc: ${err.message}`); - writeLogEventDocOnError(esContext, doc); - } - }); -} - -// whew, the thing that actually writes the event log document! -async function indexLogEventDoc(esContext: EsContext, doc: unknown) { - esContext.logger.debug(`writing to event log: ${JSON.stringify(doc)}`); - const success = await esContext.waitTillReady(); - if (!success) { - esContext.logger.debug(`event log did not initialize correctly, event not written`); - return; - } - - await esContext.esAdapter.indexDocument(doc); - esContext.logger.debug(`writing to event log complete`); -} - -// TODO: write log entry to a bounded queue buffer -function writeLogEventDocOnError(esContext: EsContext, doc: unknown) { - esContext.logger.warn(`unable to write event doc: ${JSON.stringify(doc)}`); + esContext.esAdapter.indexDocument(doc); } diff --git a/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts b/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts deleted file mode 100644 index b30d83f24f261a..00000000000000 --- a/x-pack/plugins/event_log/server/lib/bounded_queue.test.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createBoundedQueue } from './bounded_queue'; -import { loggingSystemMock } from 'src/core/server/mocks'; - -const loggingService = loggingSystemMock.create(); -const logger = loggingService.get(); - -describe('basic', () => { - let discardedHelper: DiscardedHelper<number>; - let onDiscarded: (object: number) => void; - let queue2: ReturnType<typeof createBoundedQueue>; - let queue10: ReturnType<typeof createBoundedQueue>; - - beforeAll(() => { - discardedHelper = new DiscardedHelper(); - onDiscarded = discardedHelper.onDiscarded.bind(discardedHelper); - }); - - beforeEach(() => { - queue2 = createBoundedQueue<number>({ logger, maxLength: 2, onDiscarded }); - queue10 = createBoundedQueue<number>({ logger, maxLength: 10, onDiscarded }); - }); - - test('queued items: 0', () => { - discardedHelper.reset(); - expect(queue2.isEmpty()).toEqual(true); - expect(queue2.isFull()).toEqual(false); - expect(queue2.isCloseToFull()).toEqual(false); - expect(queue2.length).toEqual(0); - expect(queue2.maxLength).toEqual(2); - expect(queue2.pull(1)).toEqual([]); - expect(queue2.pull(100)).toEqual([]); - expect(discardedHelper.discarded).toEqual([]); - }); - - test('queued items: 1', () => { - discardedHelper.reset(); - queue2.push(1); - expect(queue2.isEmpty()).toEqual(false); - expect(queue2.isFull()).toEqual(false); - expect(queue2.isCloseToFull()).toEqual(false); - expect(queue2.length).toEqual(1); - expect(queue2.maxLength).toEqual(2); - expect(queue2.pull(1)).toEqual([1]); - expect(queue2.pull(1)).toEqual([]); - expect(discardedHelper.discarded).toEqual([]); - }); - - test('queued items: 2', () => { - discardedHelper.reset(); - queue2.push(1); - queue2.push(2); - expect(queue2.isEmpty()).toEqual(false); - expect(queue2.isFull()).toEqual(true); - expect(queue2.isCloseToFull()).toEqual(true); - expect(queue2.length).toEqual(2); - expect(queue2.maxLength).toEqual(2); - expect(queue2.pull(1)).toEqual([1]); - expect(queue2.pull(1)).toEqual([2]); - expect(queue2.pull(1)).toEqual([]); - expect(discardedHelper.discarded).toEqual([]); - }); - - test('queued items: 3', () => { - discardedHelper.reset(); - queue2.push(1); - queue2.push(2); - queue2.push(3); - expect(queue2.isEmpty()).toEqual(false); - expect(queue2.isFull()).toEqual(true); - expect(queue2.isCloseToFull()).toEqual(true); - expect(queue2.length).toEqual(2); - expect(queue2.maxLength).toEqual(2); - expect(queue2.pull(1)).toEqual([2]); - expect(queue2.pull(1)).toEqual([3]); - expect(queue2.pull(1)).toEqual([]); - expect(discardedHelper.discarded).toEqual([1]); - }); - - test('closeToFull()', () => { - discardedHelper.reset(); - - expect(queue10.isCloseToFull()).toEqual(false); - - for (let i = 1; i <= 8; i++) { - queue10.push(i); - expect(queue10.isCloseToFull()).toEqual(false); - } - - queue10.push(9); - expect(queue10.isCloseToFull()).toEqual(true); - - queue10.push(10); - expect(queue10.isCloseToFull()).toEqual(true); - - queue10.pull(2); - expect(queue10.isCloseToFull()).toEqual(false); - - queue10.push(11); - expect(queue10.isCloseToFull()).toEqual(true); - }); - - test('discarded', () => { - discardedHelper.reset(); - queue2.push(1); - queue2.push(2); - queue2.push(3); - expect(discardedHelper.discarded).toEqual([1]); - - discardedHelper.reset(); - queue2.push(4); - queue2.push(5); - expect(discardedHelper.discarded).toEqual([2, 3]); - }); - - test('pull', () => { - discardedHelper.reset(); - - expect(queue10.pull(4)).toEqual([]); - - for (let i = 1; i <= 10; i++) { - queue10.push(i); - } - - expect(queue10.pull(4)).toEqual([1, 2, 3, 4]); - expect(queue10.length).toEqual(6); - expect(queue10.pull(4)).toEqual([5, 6, 7, 8]); - expect(queue10.length).toEqual(2); - expect(queue10.pull(4)).toEqual([9, 10]); - expect(queue10.length).toEqual(0); - expect(queue10.pull(1)).toEqual([]); - expect(queue10.pull(4)).toEqual([]); - }); -}); - -class DiscardedHelper<T> { - private _discarded: T[]; - - constructor() { - this.reset(); - this._discarded = []; - this.onDiscarded = this.onDiscarded.bind(this); - } - - onDiscarded(object: T) { - this._discarded.push(object); - } - - public get discarded(): T[] { - return this._discarded; - } - - reset() { - this._discarded = []; - } -} diff --git a/x-pack/plugins/event_log/server/lib/bounded_queue.ts b/x-pack/plugins/event_log/server/lib/bounded_queue.ts deleted file mode 100644 index 2c5ebcd38f5a8a..00000000000000 --- a/x-pack/plugins/event_log/server/lib/bounded_queue.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Plugin } from '../plugin'; - -const CLOSE_TO_FULL_PERCENT = 0.9; - -type SystemLogger = Plugin['systemLogger']; - -export interface IBoundedQueue<T> { - maxLength: number; - length: number; - push(object: T): void; - pull(count: number): T[]; - isEmpty(): boolean; - isFull(): boolean; - isCloseToFull(): boolean; -} - -export interface CreateBoundedQueueParams<T> { - maxLength: number; - onDiscarded(object: T): void; - logger: SystemLogger; -} - -export function createBoundedQueue<T>(params: CreateBoundedQueueParams<T>): IBoundedQueue<T> { - if (params.maxLength <= 0) throw new Error(`invalid bounded queue maxLength ${params.maxLength}`); - - return new BoundedQueue<T>(params); -} - -class BoundedQueue<T> implements IBoundedQueue<T> { - private _maxLength: number; - private _buffer: T[]; - private _onDiscarded: (object: T) => void; - private _logger: SystemLogger; - - constructor(params: CreateBoundedQueueParams<T>) { - this._maxLength = params.maxLength; - this._buffer = []; - this._onDiscarded = params.onDiscarded; - this._logger = params.logger; - } - - public get maxLength(): number { - return this._maxLength; - } - - public get length(): number { - return this._buffer.length; - } - - isEmpty() { - return this._buffer.length === 0; - } - - isFull() { - return this._buffer.length >= this._maxLength; - } - - isCloseToFull() { - return this._buffer.length / this._maxLength >= CLOSE_TO_FULL_PERCENT; - } - - push(object: T) { - this.ensureRoom(); - this._buffer.push(object); - } - - pull(count: number) { - if (count <= 0) throw new Error(`invalid pull count ${count}`); - - return this._buffer.splice(0, count); - } - - private ensureRoom() { - if (this.length < this._maxLength) return; - - const discarded = this.pull(this.length - this._maxLength + 1); - for (const object of discarded) { - try { - this._onDiscarded(object!); - } catch (err) { - this._logger.warn(`error discarding circular buffer entry: ${err.message}`); - } - } - } -} diff --git a/x-pack/plugins/event_log/server/lib/ready_signal.ts b/x-pack/plugins/event_log/server/lib/ready_signal.ts index 58879649b83cb3..706f3e79cc2790 100644 --- a/x-pack/plugins/event_log/server/lib/ready_signal.ts +++ b/x-pack/plugins/event_log/server/lib/ready_signal.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface ReadySignal<T> { +export interface ReadySignal<T = void> { wait(): Promise<T>; signal(value: T): void; } diff --git a/x-pack/plugins/event_log/server/plugin.test.ts b/x-pack/plugins/event_log/server/plugin.test.ts new file mode 100644 index 00000000000000..e32bda9089701d --- /dev/null +++ b/x-pack/plugins/event_log/server/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, CoreStart } from 'src/core/server'; +import { coreMock } from 'src/core/server/mocks'; +import { IEventLogService } from './index'; +import { Plugin } from './plugin'; +import { spacesMock } from '../../spaces/server/mocks'; + +describe('event_log plugin', () => { + it('can setup and start', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const coreSetup = coreMock.createSetup() as CoreSetup<IEventLogService>; + const coreStart = coreMock.createStart() as CoreStart; + + const plugin = new Plugin(initializerContext); + const setup = await plugin.setup(coreSetup); + expect(typeof setup.getLogger).toBe('function'); + expect(typeof setup.getProviderActions).toBe('function'); + expect(typeof setup.isEnabled).toBe('function'); + expect(typeof setup.isIndexingEntries).toBe('function'); + expect(typeof setup.isLoggingEntries).toBe('function'); + expect(typeof setup.isProviderActionRegistered).toBe('function'); + expect(typeof setup.registerProviderActions).toBe('function'); + expect(typeof setup.registerSavedObjectProvider).toBe('function'); + + const spaces = spacesMock.createStart(); + const start = await plugin.start(coreStart, { spaces }); + expect(typeof start.getClient).toBe('function'); + }); + + it('can stop', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const mockLogger = initializerContext.logger.get(); + const coreSetup = coreMock.createSetup() as CoreSetup<IEventLogService>; + const coreStart = coreMock.createStart() as CoreStart; + + const plugin = new Plugin(initializerContext); + const spaces = spacesMock.createStart(); + await plugin.setup(coreSetup); + await plugin.start(coreStart, { spaces }); + await plugin.stop(); + expect(mockLogger.debug).toBeCalledWith('shutdown: waiting to finish'); + expect(mockLogger.debug).toBeCalledWith('shutdown: finished'); + }); +}); diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index f69850f166aee2..d85de565b4d8e4 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -115,6 +115,18 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi this.esContext.initialize(); } + // Log an error if initialiization didn't succeed. + // Note that waitTillReady() is used elsewhere as a gate to having the + // event log initialization complete - successfully or not. Other uses + // of this do not bother logging when success is false, as they are in + // paths that would cause log spamming. So we do it once, here, just to + // ensure an unsucccess initialization is logged when it occurs. + this.esContext.waitTillReady().then((success) => { + if (!success) { + this.systemLogger.error(`initialization failed, events will not be indexed`); + } + }); + // will log the event after initialization this.eventLogger.logEvent({ event: { action: ACTIONS.starting }, @@ -134,18 +146,7 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi return this.eventLogClientService; } - private createRouteHandlerContext = (): IContextProvider< - RequestHandler<unknown, unknown, unknown>, - 'eventLog' - > => { - return async (context, request) => { - return { - getEventLogClient: () => this.eventLogClientService!.getClient(request), - }; - }; - }; - - stop() { + async stop(): Promise<void> { this.systemLogger.debug('stopping plugin'); if (!this.eventLogger) throw new Error('eventLogger not initialized'); @@ -156,5 +157,20 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi event: { action: ACTIONS.stopping }, message: 'eventLog stopping', }); + + this.systemLogger.debug('shutdown: waiting to finish'); + await this.esContext?.shutdown(); + this.systemLogger.debug('shutdown: finished'); } + + private createRouteHandlerContext = (): IContextProvider< + RequestHandler<unknown, unknown, unknown>, + 'eventLog' + > => { + return async (context, request) => { + return { + getEventLogClient: () => this.eventLogClientService!.getClient(request), + }; + }; + }; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 5d79d41b7a6316..7a6f6232b2d4f4 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -252,12 +252,19 @@ export type PackageList = PackageListItem[]; export type PackageListItem = Installable<RegistrySearchResult>; export type PackagesGroupedByStatus = Record<ValueOf<InstallationStatus>, PackageList>; -export type PackageInfo = Installable< - // remove the properties we'll be altering/replacing from the base type - Omit<RegistryPackage, keyof PackageAdditions> & - // now add our replacement definitions - PackageAdditions ->; +export type PackageInfo = + | Installable< + // remove the properties we'll be altering/replacing from the base type + Omit<RegistryPackage, keyof PackageAdditions> & + // now add our replacement definitions + PackageAdditions + > + | Installable< + // remove the properties we'll be altering/replacing from the base type + Omit<ArchivePackage, keyof PackageAdditions> & + // now add our replacement definitions + PackageAdditions + >; export interface Installation extends SavedObjectAttributes { installed_kibana: KibanaAssetReference[]; diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json index 81b56682b47e16..2fcbef75b9832b 100644 --- a/x-pack/plugins/fleet/kibana.json +++ b/x-pack/plugins/fleet/kibana.json @@ -7,5 +7,5 @@ "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], "extraPublicDirs": ["common"], - "requiredBundles": ["kibanaReact", "esUiShared", "home"] + "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"] } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx index a22e4e14055e3a..9ebc8ea9380a9f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx @@ -9,7 +9,7 @@ import { IFieldType } from 'src/plugins/data/public'; // @ts-ignore import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useDebounce, useStartDeps } from '../hooks'; +import { useDebounce, useStartServices } from '../hooks'; import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants'; const DEBOUNCE_SEARCH_MS = 150; @@ -80,7 +80,7 @@ export const SearchBar: React.FunctionComponent<Props> = ({ ); }; -function transformSuggestionType(type: string): { iconType: string; color: string } { +export function transformSuggestionType(type: string): { iconType: string; color: string } { switch (type) { case 'field': return { iconType: 'kqlField', color: 'tint4' }; @@ -96,7 +96,7 @@ function transformSuggestionType(type: string): { iconType: string; color: strin } function useSuggestions(fieldPrefix: string, search: string) { - const { data } = useStartDeps(); + const { data } = useStartServices(); const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH_MS); const [suggestions, setSuggestions] = useState<Suggestion[]>([]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx index 80ecaa24932785..639a3e41b39fa8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx @@ -25,7 +25,13 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText } from '@elastic/eui'; import { safeLoad } from 'js-yaml'; -import { useComboInput, useCore, useGetSettings, useInput, sendPutSettings } from '../hooks'; +import { + useComboInput, + useStartServices, + useGetSettings, + useInput, + sendPutSettings, +} from '../hooks'; import { useGetOutputs, sendPutOutput } from '../hooks/use_request/outputs'; import { isDiffPathProtocol } from '../../../../common/'; @@ -37,7 +43,7 @@ interface Props { function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { const [isLoading, setIsloading] = React.useState(false); - const { notifications } = useCore(); + const { notifications } = useStartServices(); const kibanaUrlsInput = useComboInput([], (value) => { if (value.length === 0) { return [ diff --git a/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts b/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts index 9963753651671a..ecd4227a54b655 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts @@ -51,8 +51,7 @@ export const PAGE_ROUTING_PATHS = { fleet: '/fleet', fleet_agent_list: '/fleet/agents', fleet_agent_details: '/fleet/agents/:agentId/:tabId?', - fleet_agent_details_events: '/fleet/agents/:agentId', - fleet_agent_details_details: '/fleet/agents/:agentId/details', + fleet_agent_details_logs: '/fleet/agents/:agentId/logs', fleet_enrollment_tokens: '/fleet/enrollment-tokens', data_streams: '/data-streams', }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts index 29843f6a3e5b1d..6026a5579f65b6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts @@ -5,10 +5,9 @@ */ export { useCapabilities } from './use_capabilities'; -export { useCore } from './use_core'; +export { useStartServices } from './use_core'; export { useConfig, ConfigContext } from './use_config'; export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; -export { useSetupDeps, useStartDeps, DepsContext } from './use_deps'; export { licenseService, useLicense } from './use_license'; export { useBreadcrumbs } from './use_breadcrumbs'; export { useLink } from './use_link'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index ed38e1a5ce4a13..40654645ecd3f2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { ChromeBreadcrumb } from 'src/core/public'; import { BASE_PATH, Page, DynamicPagePathValues, pagePathGetters } from '../constants'; -import { useCore } from './use_core'; +import { useStartServices } from './use_core'; const BASE_BREADCRUMB: ChromeBreadcrumb = { href: pagePathGetters.overview(), @@ -204,7 +204,7 @@ const breadcrumbGetters: { }; export function useBreadcrumbs(page: Page, values: DynamicPagePathValues = {}) { - const { chrome, http } = useCore(); + const { chrome, http } = useStartServices(); const breadcrumbs: ChromeBreadcrumb[] = breadcrumbGetters[page](values).map((breadcrumb) => ({ ...breadcrumb, href: breadcrumb.href ? http.basePath.prepend(`${BASE_PATH}#${breadcrumb.href}`) : undefined, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_capabilities.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_capabilities.ts index d8535183bb84e9..da5be82049c8e5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_capabilities.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_capabilities.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useCore } from './'; +import { useStartServices } from './'; export function useCapabilities() { - const core = useCore(); + const core = useStartServices(); return core.application.capabilities.fleet; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_core.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_core.ts index dad2eaa1d8e0f3..f425831f6d6bca 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_core.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_core.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart } from 'kibana/public'; +import { FleetStartServices } from '../../../plugin'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -export function useCore(): CoreStart { - const { services } = useKibana<CoreStart>(); +export function useStartServices(): FleetStartServices { + const { services } = useKibana<FleetStartServices>(); if (services === null) { throw new Error('KibanaContextProvider not initialized'); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_deps.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_deps.ts deleted file mode 100644 index bf8f33297882e4..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_deps.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext } from 'react'; -import { FleetSetupDeps, FleetStartDeps } from '../../../plugin'; - -export const DepsContext = React.createContext<{ - setup: FleetSetupDeps; - start: FleetStartDeps; -} | null>(null); - -export function useSetupDeps() { - const deps = useContext(DepsContext); - if (deps === null) { - throw new Error('DepsContext not initialized'); - } - return deps.setup; -} - -export function useStartDeps() { - const deps = useContext(DepsContext); - if (deps === null) { - throw new Error('StartDepsContext not initialized'); - } - return deps.start; -} diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_kibana_link.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_kibana_link.ts index 58537b2075c160..5faa3bfcab4af8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_kibana_link.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_kibana_link.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useCore } from './'; +import { useStartServices } from './'; const KIBANA_BASE_PATH = '/app/kibana'; export function useKibanaLink(path: string = '/') { - const core = useCore(); + const core = useStartServices(); return core.http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts index 1b17c5cb0b1f36..40c0689905932b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_link.ts @@ -11,14 +11,14 @@ import { DynamicPagePathValues, pagePathGetters, } from '../constants'; -import { useCore } from './'; +import { useStartServices } from './'; const getPath = (page: StaticPage | DynamicPage, values: DynamicPagePathValues = {}): string => { return values ? pagePathGetters[page](values) : pagePathGetters[page as StaticPage](); }; export const useLink = () => { - const core = useCore(); + const core = useStartServices(); return { getPath, getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/index.tsx index 51c897b3661ccb..61a5f1eabc2afe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/index.tsx @@ -14,16 +14,15 @@ import { EuiErrorBoundary, EuiPanel, EuiEmptyPrompt, EuiCode } from '@elastic/eu import { CoreStart, AppMountParameters } from 'src/core/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { EuiThemeProvider } from '../../../../xpack_legacy/common'; -import { FleetSetupDeps, FleetConfigType, FleetStartDeps } from '../../plugin'; +import { FleetConfigType, FleetStartServices } from '../../plugin'; import { PAGE_ROUTING_PATHS } from './constants'; import { DefaultLayout, WithoutHeaderLayout } from './layouts'; import { Loading, Error } from './components'; import { IngestManagerOverview, EPMApp, AgentPolicyApp, FleetApp, DataStreamApp } from './sections'; import { - DepsContext, ConfigContext, useConfig, - useCore, + useStartServices, sendSetup, sendGetPermissionsCheck, licenseService, @@ -67,7 +66,7 @@ const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basep useBreadcrumbs('base'); const { agents } = useConfig(); - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [isPermissionsLoading, setIsPermissionsLoading] = useState<boolean>(false); const [permissionsError, setPermissionsError] = useState<string>(); @@ -227,48 +226,40 @@ const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basep const IngestManagerApp = ({ basepath, - coreStart, - setupDeps, - startDeps, + startServices, config, history, kibanaVersion, extensions, }: { basepath: string; - coreStart: CoreStart; - setupDeps: FleetSetupDeps; - startDeps: FleetStartDeps; + startServices: FleetStartServices; config: FleetConfigType; history: AppMountParameters['history']; kibanaVersion: string; extensions: UIExtensionsStorage; }) => { - const isDarkMode = useObservable<boolean>(coreStart.uiSettings.get$('theme:darkMode')); + const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode')); return ( - <coreStart.i18n.Context> - <KibanaContextProvider services={{ ...coreStart }}> - <DepsContext.Provider value={{ setup: setupDeps, start: startDeps }}> - <ConfigContext.Provider value={config}> - <KibanaVersionContext.Provider value={kibanaVersion}> - <EuiThemeProvider darkMode={isDarkMode}> - <UIExtensionsContext.Provider value={extensions}> - <IngestManagerRoutes history={history} basepath={basepath} /> - </UIExtensionsContext.Provider> - </EuiThemeProvider> - </KibanaVersionContext.Provider> - </ConfigContext.Provider> - </DepsContext.Provider> + <startServices.i18n.Context> + <KibanaContextProvider services={{ ...startServices }}> + <ConfigContext.Provider value={config}> + <KibanaVersionContext.Provider value={kibanaVersion}> + <EuiThemeProvider darkMode={isDarkMode}> + <UIExtensionsContext.Provider value={extensions}> + <IngestManagerRoutes history={history} basepath={basepath} /> + </UIExtensionsContext.Provider> + </EuiThemeProvider> + </KibanaVersionContext.Provider> + </ConfigContext.Provider> </KibanaContextProvider> - </coreStart.i18n.Context> + </startServices.i18n.Context> ); }; export function renderApp( - coreStart: CoreStart, + startServices: FleetStartServices, { element, appBasePath, history }: AppMountParameters, - setupDeps: FleetSetupDeps, - startDeps: FleetStartDeps, config: FleetConfigType, kibanaVersion: string, extensions: UIExtensionsStorage @@ -276,9 +267,7 @@ export function renderApp( ReactDOM.render( <IngestManagerApp basepath={appBasePath} - coreStart={coreStart} - setupDeps={setupDeps} - startDeps={startDeps} + startServices={startServices} config={config} history={history} kibanaVersion={kibanaVersion} diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 376de7e2e6a075..93bfe489a1bf4a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -26,6 +26,12 @@ const Container = styled.div` flex-direction: column; `; +const Wrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; +`; + const Nav = styled.nav` background: ${(props) => props.theme.eui.euiColorEmptyShade}; border-bottom: ${(props) => props.theme.eui.euiBorderThin}; @@ -56,7 +62,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({ /> )} <Container> - <div> + <Wrapper> <Nav> <EuiFlexGroup gutterSize="l" alignItems="center"> <EuiFlexItem> @@ -126,7 +132,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({ </EuiFlexGroup> </Nav> {children} - </div> + </Wrapper> <AlphaMessaging /> </Container> </> diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx index 03efe20f96a51c..e49ef152f8306d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ export { DefaultLayout } from './default'; -export { WithHeaderLayout } from './with_header'; +export { WithHeaderLayout, WithHeaderLayoutProps } from './with_header'; export { WithoutHeaderLayout } from './without_header'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/with_header.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/with_header.tsx index 4b21a15a736455..bca0e2889483f6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/with_header.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/with_header.tsx @@ -4,13 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { EuiPageBody, EuiSpacer } from '@elastic/eui'; import { Header, HeaderProps } from '../components'; - -const Page = styled(EuiPage)` - background: ${(props) => props.theme.eui.euiColorEmptyShade}; -`; +import { Page, ContentWrapper } from './without_header'; export interface WithHeaderLayoutProps extends HeaderProps { restrictWidth?: number; @@ -37,8 +33,10 @@ export const WithHeaderLayout: React.FC<WithHeaderLayoutProps> = ({ data-test-subj={dataTestSubj ? `${dataTestSubj}_page` : undefined} > <EuiPageBody> - <EuiSpacer size="m" /> - {children} + <ContentWrapper> + <EuiSpacer size="m" /> + {children} + </ContentWrapper> </EuiPageBody> </Page> </Fragment> diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/without_header.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/without_header.tsx index 08f6244242a3d0..93ad9977800156 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/without_header.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/without_header.tsx @@ -7,8 +7,17 @@ import React, { Fragment } from 'react'; import styled from 'styled-components'; import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; -const Page = styled(EuiPage)` +export const Page = styled(EuiPage)` background: ${(props) => props.theme.eui.euiColorEmptyShade}; + width: 100%; + align-self: center; + margin-left: 0; + margin-right: 0; + flex: 1; +`; + +export const ContentWrapper = styled.div` + height: 100%; `; interface Props { @@ -20,8 +29,10 @@ export const WithoutHeaderLayout: React.FC<Props> = ({ restrictWidth, children } <Fragment> <Page restrictWidth={restrictWidth || 1200}> <EuiPageBody> - <EuiSpacer size="m" /> - {children} + <ContentWrapper> + <EuiSpacer size="m" /> + {children} + </ContentWrapper> </EuiPageBody> </Page> </Fragment> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx index 41201f9612f13e..9e2a7ae8f8f47b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx @@ -9,7 +9,7 @@ import { EuiConfirmModal, EuiOverlayMask, EuiFormRow, EuiFieldText } from '@elas import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../types'; -import { sendCopyAgentPolicy, useCore } from '../../../hooks'; +import { sendCopyAgentPolicy, useStartServices } from '../../../hooks'; interface Props { children: (copyAgentPolicy: CopyAgentPolicy) => React.ReactElement; @@ -20,7 +20,7 @@ export type CopyAgentPolicy = (agentPolicy: AgentPolicy, onSuccess?: OnSuccessCa type OnSuccessCallback = (newAgentPolicy: AgentPolicy) => void; export const AgentPolicyCopyProvider: React.FunctionComponent<Props> = ({ children }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [agentPolicy, setAgentPolicy] = useState<AgentPolicy>(); const [newAgentPolicy, setNewAgentPolicy] = useState<Pick<AgentPolicy, 'name' | 'description'>>(); const [isModalOpen, setIsModalOpen] = useState<boolean>(false); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx index 41704f69958a01..7afb028dded2a2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -9,7 +9,7 @@ import { EuiConfirmModal, EuiOverlayMask, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; -import { sendDeleteAgentPolicy, useCore, useConfig, sendRequest } from '../../../hooks'; +import { sendDeleteAgentPolicy, useStartServices, useConfig, sendRequest } from '../../../hooks'; interface Props { children: (deleteAgentPolicy: DeleteAgentPolicy) => React.ReactElement; @@ -20,7 +20,7 @@ export type DeleteAgentPolicy = (agentPolicy: string, onSuccess?: OnSuccessCallb type OnSuccessCallback = (agentPolicyDeleted: string) => void; export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ children }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx index 773d53484147aa..7b0075e160c47f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx @@ -20,7 +20,7 @@ import { EuiButton, EuiCallOut, } from '@elastic/eui'; -import { useGetOneAgentPolicyFull, useGetOneAgentPolicy, useCore } from '../../../hooks'; +import { useGetOneAgentPolicyFull, useGetOneAgentPolicy, useStartServices } from '../../../hooks'; import { Loading } from '../../../components'; import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services'; @@ -32,7 +32,7 @@ const FlyoutBody = styled(EuiFlyoutBody)` export const AgentPolicyYamlFlyout = memo<{ policyId: string; onClose: () => void }>( ({ policyId, onClose }) => { - const core = useCore(); + const core = useStartServices(); const { isLoading: isLoadingYaml, data: yamlData, error } = useGetOneAgentPolicyFull(policyId); const { data: agentPolicyData } = useGetOneAgentPolicy(policyId); const body = isLoadingYaml ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx index 8de40edc403311..e86ac9e3bd03c4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx @@ -8,7 +8,7 @@ import React, { Fragment, useMemo, useRef, useState } from 'react'; import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useCore, sendRequest, sendDeletePackagePolicy, useConfig } from '../../../hooks'; +import { useStartServices, sendRequest, sendDeletePackagePolicy, useConfig } from '../../../hooks'; import { AGENT_API_ROUTES, AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { AgentPolicy } from '../../../types'; @@ -28,7 +28,7 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ agentPolicy, children, }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index a837ed33e41106..62792b84105abb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -28,7 +28,7 @@ import { useLink, useBreadcrumbs, sendCreatePackagePolicy, - useCore, + useStartServices, useConfig, sendGetAgentStatus, } from '../../../hooks'; @@ -60,7 +60,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const { notifications, application: { navigateToApp }, - } = useCore(); + } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx index fe3955c84dec3e..b33976d53fe95e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx @@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../../../types'; import { useLink, - useCore, + useStartServices, useCapabilities, sendUpdateAgentPolicy, useConfig, @@ -33,7 +33,7 @@ const FormWrapper = styled.div` export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( ({ agentPolicy: originalAgentPolicy }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx index 7528c923f0abde..0099fb3c84d128 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx @@ -26,7 +26,7 @@ import { useGetOneAgentPolicy, useLink, useBreadcrumbs, - useCore, + useStartServices, useFleetStatus, } from '../../../hooks'; import { Loading, Error } from '../../../components'; @@ -56,7 +56,7 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => { const { refreshAgentStatus } = agentStatusRequest; const { application: { navigateToApp }, - } = useCore(); + } = useStartServices(); const routeState = useIntraAppState<AgentPolicyDetailsDeployAgentAction>(); const agentStatus = agentStatusRequest.data?.results; const queryParams = new URLSearchParams(useLocation().search); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index bfc10848d378f9..c0db51873e52ef 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -19,7 +19,7 @@ import { AgentPolicy, PackageInfo, UpdatePackagePolicy } from '../../../types'; import { useLink, useBreadcrumbs, - useCore, + useStartServices, useConfig, sendUpdatePackagePolicy, sendGetAgentStatus, @@ -47,7 +47,7 @@ import { GetOnePackagePolicyResponse } from '../../../../../../common/types/rest import { PackagePolicyEditExtensionComponentProps } from '../../../types'; export const EditPackagePolicyPage: React.FunctionComponent = () => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx index f10f36174fe822..364df44a59e186 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx @@ -23,7 +23,7 @@ import { } from '@elastic/eui'; import { dataTypes } from '../../../../../../../common'; import { NewAgentPolicy, AgentPolicy } from '../../../../types'; -import { useCapabilities, useCore, sendCreateAgentPolicy } from '../../../../hooks'; +import { useCapabilities, useStartServices, sendCreateAgentPolicy } from '../../../../hooks'; import { AgentPolicyForm, agentPolicyFormValidation } from '../../components'; const FlyoutWithHigherZIndex = styled(EuiFlyout)` @@ -38,7 +38,7 @@ export const CreateAgentPolicyFlyout: React.FunctionComponent<Props> = ({ onClose, ...restOfProps }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const hasWriteCapabilites = useCapabilities().write; const [agentPolicy, setAgentPolicy] = useState<NewAgentPolicy>({ name: '', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_events_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_events_table.tsx deleted file mode 100644 index c1a1b3862728db..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_events_table.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { - EuiBasicTable, - // @ts-ignore - EuiSuggest, - EuiFlexGroup, - EuiButton, - EuiSpacer, - EuiFlexItem, - EuiBadge, - EuiText, - EuiButtonIcon, - EuiCodeBlock, -} from '@elastic/eui'; -import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, FormattedTime } from '@kbn/i18n/react'; -import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../../constants'; -import { Agent, AgentEvent } from '../../../../types'; -import { usePagination, useGetOneAgentEvents } from '../../../../hooks'; -import { SearchBar } from '../../../../components/search_bar'; -import { TYPE_LABEL, SUBTYPE_LABEL } from './type_labels'; - -function useSearch() { - const [state, setState] = useState<{ search: string }>({ - search: '', - }); - - const setSearch = (s: string) => - setState({ - search: s, - }); - - return { - ...state, - setSearch, - }; -} - -export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { - const { pageSizeOptions, pagination, setPagination } = usePagination(); - const { search, setSearch } = useSearch(); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ - [key: string]: JSX.Element; - }>({}); - - const { isLoading, data, resendRequest } = useGetOneAgentEvents(agent.id, { - page: pagination.currentPage, - perPage: pagination.pageSize, - kuery: search && search.trim() !== '' ? search.trim() : undefined, - }); - - const refresh = () => resendRequest(); - - const total = data ? data.total : 0; - const list = data ? data.list : []; - const paginationOptions = { - pageIndex: pagination.currentPage - 1, - pageSize: pagination.pageSize, - totalItemCount: total, - pageSizeOptions, - }; - - const toggleDetails = (agentEvent: AgentEvent) => { - const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - if (itemIdToExpandedRowMapValues[agentEvent.id]) { - delete itemIdToExpandedRowMapValues[agentEvent.id]; - } else { - const details = ( - <div style={{ width: '100%' }}> - <div> - <EuiText size="s"> - <strong> - <FormattedMessage - id="xpack.fleet.agentEventsList.messageDetailsTitle" - defaultMessage="Message" - /> - </strong> - <EuiSpacer size="xs" /> - <p>{agentEvent.message}</p> - </EuiText> - </div> - {agentEvent.payload ? ( - <div> - <EuiSpacer size="s" /> - <EuiText size="s"> - <strong> - <FormattedMessage - id="xpack.fleet.agentEventsList.payloadDetailsTitle" - defaultMessage="Payload" - /> - </strong> - </EuiText> - <EuiSpacer size="xs" /> - <EuiCodeBlock language="json" paddingSize="s" overflowHeight={200}> - {JSON.stringify(agentEvent.payload, null, 2)} - </EuiCodeBlock> - </div> - ) : null} - </div> - ); - itemIdToExpandedRowMapValues[agentEvent.id] = details; - } - setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); - }; - - const columns = [ - { - field: 'timestamp', - name: i18n.translate('xpack.fleet.agentEventsList.timestampColumnTitle', { - defaultMessage: 'Timestamp', - }), - render: (timestamp: string) => ( - <FormattedTime - value={new Date(timestamp)} - month="short" - day="numeric" - year="numeric" - hour="numeric" - minute="numeric" - second="numeric" - /> - ), - sortable: true, - width: '18%', - }, - { - field: 'type', - name: i18n.translate('xpack.fleet.agentEventsList.typeColumnTitle', { - defaultMessage: 'Type', - }), - width: '10%', - render: (type: AgentEvent['type']) => - TYPE_LABEL[type] || <EuiBadge color="hollow">{type}</EuiBadge>, - }, - { - field: 'subtype', - name: i18n.translate('xpack.fleet.agentEventsList.subtypeColumnTitle', { - defaultMessage: 'Subtype', - }), - width: '13%', - render: (subtype: AgentEvent['subtype']) => - SUBTYPE_LABEL[subtype] || <EuiBadge color="hollow">{subtype}</EuiBadge>, - }, - { - field: 'message', - name: i18n.translate('xpack.fleet.agentEventsList.messageColumnTitle', { - defaultMessage: 'Message', - }), - render: (value: string) => ( - <EuiText size="xs" className="eui-textTruncate"> - {value} - </EuiText> - ), - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: (agentEvent: AgentEvent) => ( - <EuiButtonIcon - onClick={() => toggleDetails(agentEvent)} - aria-label={ - itemIdToExpandedRowMap[agentEvent.id] - ? i18n.translate('xpack.fleet.agentEventsList.collapseDetailsAriaLabel', { - defaultMessage: 'Hide details', - }) - : i18n.translate('xpack.fleet.agentEventsList.expandDetailsAriaLabel', { - defaultMessage: 'Show details', - }) - } - iconType={itemIdToExpandedRowMap[agentEvent.id] ? 'arrowUp' : 'arrowDown'} - /> - ), - }, - ]; - - const onClickRefresh = () => { - refresh(); - }; - - const onChange = ({ page }: { page: { index: number; size: number } }) => { - const newPagination = { - ...pagination, - currentPage: page.index + 1, - pageSize: page.size, - }; - - setPagination(newPagination); - }; - - return ( - <> - <EuiFlexGroup> - <EuiFlexItem> - <SearchBar - value={search} - onChange={setSearch} - fieldPrefix={AGENT_EVENT_SAVED_OBJECT_TYPE} - placeholder={i18n.translate('xpack.fleet.agentEventsList.searchPlaceholderText', { - defaultMessage: 'Search for activity logs', - })} - /> - </EuiFlexItem> - <EuiFlexItem grow={null}> - <EuiButton iconType="refresh" onClick={onClickRefresh}> - <FormattedMessage - id="xpack.fleet.agentEventsList.refreshButton" - defaultMessage="Refresh" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="m" /> - <EuiBasicTable<AgentEvent> - onChange={onChange} - items={list} - itemId="id" - columns={columns} - pagination={paginationOptions} - loading={isLoading} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - /> - </> - ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.test.ts new file mode 100644 index 00000000000000..610c2feacf99e1 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { buildQuery } from './build_query'; + +describe('Fleet - buildQuery', () => { + it('should work', () => { + expect( + buildQuery({ agentId: 'some-agent-id', datasets: [], logLevels: [], userQuery: '' }) + ).toEqual( + 'elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.*)' + ); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: ['elastic_agent'], + logLevels: [], + userQuery: '', + }) + ).toEqual('elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent)'); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: ['elastic_agent', 'elastic_agent.filebeat'], + logLevels: ['error'], + userQuery: '', + }) + ).toEqual( + 'elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.filebeat) and (log.level:error)' + ); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: [], + logLevels: ['error', 'info', 'warn'], + userQuery: '', + }) + ).toEqual( + 'elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.*) and (log.level:error or log.level:info or log.level:warn)' + ); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: ['elastic_agent'], + logLevels: ['error', 'info', 'warn'], + userQuery: 'FLEET_GATEWAY and input.type:*', + }) + ).toEqual( + '(elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent) and (log.level:error or log.level:info or log.level:warn)) and (FLEET_GATEWAY and input.type:*)' + ); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: [], + logLevels: [], + userQuery: 'FLEET_GATEWAY and input.type:*', + }) + ).toEqual( + '(elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.*)) and (FLEET_GATEWAY and input.type:*)' + ); + + expect( + buildQuery({ + agentId: 'some-agent-id', + datasets: [], + logLevels: ['error'], + userQuery: 'FLEET_GATEWAY and input.type:*', + }) + ).toEqual( + '(elastic_agent.id:some-agent-id and (data_stream.dataset:elastic_agent or data_stream.dataset:elastic_agent.*) and (log.level:error)) and (FLEET_GATEWAY and input.type:*)' + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.ts new file mode 100644 index 00000000000000..39d383cad503d2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/build_query.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + DATASET_FIELD, + AGENT_DATASET, + AGENT_DATASET_PATTERN, + LOG_LEVEL_FIELD, + AGENT_ID_FIELD, +} from './constants'; + +export const buildQuery = ({ + agentId, + datasets, + logLevels, + userQuery, +}: { + agentId: string; + datasets: string[]; + logLevels: string[]; + userQuery: string; +}): string => { + // Filter on agent ID + const agentIdQuery = `${AGENT_ID_FIELD.name}:${agentId}`; + + // Filter on selected datasets if given, fall back to filtering on dataset: elastic_agent|elastic_agent.* + const datasetQuery = datasets.length + ? datasets.map((dataset) => `${DATASET_FIELD.name}:${dataset}`).join(' or ') + : `${DATASET_FIELD.name}:${AGENT_DATASET} or ${DATASET_FIELD.name}:${AGENT_DATASET_PATTERN}`; + + // Filter on log levels + const logLevelQuery = logLevels.map((level) => `${LOG_LEVEL_FIELD.name}:${level}`).join(' or '); + + // Agent ID + datasets query + const agentQuery = `${agentIdQuery} and (${datasetQuery})`; + + // Agent ID + datasets + log levels query + const baseQuery = logLevelQuery ? `${agentQuery} and (${logLevelQuery})` : agentQuery; + + // Agent ID + datasets + log levels + user input query + const finalQuery = userQuery ? `(${baseQuery}) and (${userQuery})` : baseQuery; + + return finalQuery; +}; 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 new file mode 100644 index 00000000000000..b56e27356ef342 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export const AGENT_LOG_INDEX_PATTERN = 'logs-elastic_agent-*,logs-elastic_agent.*-*'; +export const AGENT_DATASET = 'elastic_agent'; +export const AGENT_DATASET_PATTERN = 'elastic_agent.*'; +export const AGENT_ID_FIELD = { + name: 'elastic_agent.id', + type: 'string', +}; +export const DATASET_FIELD = { + name: 'data_stream.dataset', + type: 'string', + aggregatable: true, +}; +export const LOG_LEVEL_FIELD = { + name: 'log.level', + type: 'string', + aggregatable: true, +}; +export const DEFAULT_DATE_RANGE = { + start: 'now-1d', + end: 'now', +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_dataset.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_dataset.tsx new file mode 100644 index 00000000000000..bc3cfd84d2379a --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_dataset.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useState, useEffect } from 'react'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useStartServices } from '../../../../../hooks'; +import { AGENT_LOG_INDEX_PATTERN, DATASET_FIELD, AGENT_DATASET } from './constants'; + +export const DatasetFilter: React.FunctionComponent<{ + selectedDatasets: string[]; + onToggleDataset: (dataset: string) => void; +}> = memo(({ selectedDatasets, onToggleDataset }) => { + const { data } = useStartServices(); + const [isOpen, setIsOpen] = useState<boolean>(false); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [datasetValues, setDatasetValues] = useState<string[]>([AGENT_DATASET]); + + useEffect(() => { + const fetchValues = async () => { + setIsLoading(true); + try { + const values = await data.autocomplete.getValueSuggestions({ + indexPattern: { + title: AGENT_LOG_INDEX_PATTERN, + fields: [DATASET_FIELD], + }, + field: DATASET_FIELD, + query: '', + }); + setDatasetValues(values.sort()); + } catch (e) { + setDatasetValues([AGENT_DATASET]); + } + setIsLoading(false); + }; + fetchValues(); + }, [data.autocomplete]); + + return ( + <EuiPopover + button={ + <EuiFilterButton + iconType="arrowDown" + onClick={() => setIsOpen(true)} + isSelected={isOpen} + isLoading={isLoading} + numFilters={datasetValues.length} + hasActiveFilters={selectedDatasets.length > 0} + numActiveFilters={selectedDatasets.length} + > + {i18n.translate('xpack.fleet.agentLogs.datasetSelectText', { + defaultMessage: 'Dataset', + })} + </EuiFilterButton> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + panelPaddingSize="none" + > + {datasetValues.map((dataset) => ( + <EuiFilterSelectItem + checked={selectedDatasets.includes(dataset) ? 'on' : undefined} + key={dataset} + onClick={() => onToggleDataset(dataset)} + > + {dataset} + </EuiFilterSelectItem> + ))} + </EuiPopover> + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx new file mode 100644 index 00000000000000..b034168dc8a15e --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useState, useEffect } from 'react'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useStartServices } from '../../../../../hooks'; +import { AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; + +export const LogLevelFilter: React.FunctionComponent<{ + selectedLevels: string[]; + onToggleLevel: (level: string) => void; +}> = memo(({ selectedLevels, onToggleLevel }) => { + const { data } = useStartServices(); + const [isOpen, setIsOpen] = useState<boolean>(false); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [levelValues, setLevelValues] = useState<string[]>([]); + + useEffect(() => { + const fetchValues = async () => { + setIsLoading(true); + try { + const values = await data.autocomplete.getValueSuggestions({ + indexPattern: { + title: AGENT_LOG_INDEX_PATTERN, + fields: [LOG_LEVEL_FIELD], + }, + field: LOG_LEVEL_FIELD, + query: '', + }); + setLevelValues(values.sort()); + } catch (e) { + setLevelValues([]); + } + setIsLoading(false); + }; + fetchValues(); + }, [data.autocomplete]); + + return ( + <EuiPopover + button={ + <EuiFilterButton + iconType="arrowDown" + onClick={() => setIsOpen(true)} + isSelected={isOpen} + isLoading={isLoading} + numFilters={levelValues.length} + hasActiveFilters={selectedLevels.length > 0} + numActiveFilters={selectedLevels.length} + > + {i18n.translate('xpack.fleet.agentLogs.logLevelSelectText', { + defaultMessage: 'Log level', + })} + </EuiFilterButton> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + panelPaddingSize="none" + > + {levelValues.map((level) => ( + <EuiFilterSelectItem + checked={selectedLevels.includes(level) ? 'on' : undefined} + key={level} + onClick={() => onToggleLevel(level)} + > + {level} + </EuiFilterSelectItem> + ))} + </EuiPopover> + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx new file mode 100644 index 00000000000000..e033781a850a02 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo, useState, useCallback } from 'react'; +import styled from 'styled-components'; +import url from 'url'; +import { encode } from 'rison-node'; +import { stringify } from 'query-string'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + EuiFilterGroup, + EuiPanel, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; +import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; +import { LogStream } from '../../../../../../../../../infra/public'; +import { Agent } from '../../../../../types'; +import { useStartServices } from '../../../../../hooks'; +import { AGENT_DATASET, DEFAULT_DATE_RANGE } from './constants'; +import { DatasetFilter } from './filter_dataset'; +import { LogLevelFilter } from './filter_log_level'; +import { LogQueryBar } from './query_bar'; +import { buildQuery } from './build_query'; + +const WrapperFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +const DatePickerFlexItem = styled(EuiFlexItem)` + max-width: 312px; +`; + +export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agent }) => { + const { data, application, http } = useStartServices(); + + // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) + const getDateRangeTimestamps = useCallback( + (timeRange: TimeRange) => { + const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); + return min && max + ? { + startTimestamp: min.valueOf(), + endTimestamp: max.valueOf(), + } + : undefined; + }, + [data.query.timefilter.timefilter] + ); + + // Initial time range filter + const [dateRange, setDateRange] = useState<{ + startExpression: string; + endExpression: string; + startTimestamp: number; + endTimestamp: number; + }>({ + startExpression: DEFAULT_DATE_RANGE.start, + endExpression: DEFAULT_DATE_RANGE.end, + ...getDateRangeTimestamps({ from: DEFAULT_DATE_RANGE.start, to: DEFAULT_DATE_RANGE.end })!, + }); + + const tryUpdateDateRange = useCallback( + (timeRange: TimeRange) => { + const timestamps = getDateRangeTimestamps(timeRange); + if (timestamps) { + setDateRange({ + startExpression: timeRange.from, + endExpression: timeRange.to, + ...timestamps, + }); + } + }, + [getDateRangeTimestamps] + ); + + // Filters + const [selectedLogLevels, setSelectedLogLevels] = useState<string[]>([]); + const [selectedDatasets, setSelectedDatasets] = useState<string[]>([AGENT_DATASET]); + + // User query state + const [query, setQuery] = useState<string>(''); + const [draftQuery, setDraftQuery] = useState<string>(''); + const [isDraftQueryValid, setIsDraftQueryValid] = useState<boolean>(true); + const onUpdateDraftQuery = useCallback((newDraftQuery: string, runQuery?: boolean) => { + setDraftQuery(newDraftQuery); + try { + esKuery.fromKueryExpression(newDraftQuery); + setIsDraftQueryValid(true); + if (runQuery) { + setQuery(newDraftQuery); + } + } catch (err) { + setIsDraftQueryValid(false); + } + }, []); + + // Build final log stream query from agent id, datasets, log levels, and user input + const logStreamQuery = useMemo( + () => + buildQuery({ + agentId: agent.id, + datasets: selectedDatasets, + logLevels: selectedLogLevels, + userQuery: query, + }), + [agent.id, query, selectedDatasets, selectedLogLevels] + ); + + // Generate URL to pass page state to Logs UI + const viewInLogsUrl = useMemo( + () => + http.basePath.prepend( + url.format({ + pathname: '/app/logs/stream', + search: stringify( + { + logPosition: encode({ + start: dateRange.startExpression, + end: dateRange.endExpression, + streamLive: false, + }), + logFilter: encode({ + expression: logStreamQuery, + kind: 'kuery', + }), + }, + { sort: false, encode: false } + ), + }) + ), + [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] + ); + + return ( + <WrapperFlexGroup direction="column" gutterSize="m"> + <EuiFlexItem grow={false}> + <EuiFlexGroup gutterSize="m"> + <EuiFlexItem> + <LogQueryBar + query={draftQuery} + onUpdateQuery={onUpdateDraftQuery} + isQueryValid={isDraftQueryValid} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFilterGroup> + <DatasetFilter + selectedDatasets={selectedDatasets} + onToggleDataset={(level: string) => { + const currentLevels = [...selectedDatasets]; + const levelPosition = currentLevels.indexOf(level); + if (levelPosition >= 0) { + currentLevels.splice(levelPosition, 1); + setSelectedDatasets(currentLevels); + } else { + setSelectedDatasets([...selectedDatasets, level]); + } + }} + /> + <LogLevelFilter + selectedLevels={selectedLogLevels} + onToggleLevel={(level: string) => { + const currentLevels = [...selectedLogLevels]; + const levelPosition = currentLevels.indexOf(level); + if (levelPosition >= 0) { + currentLevels.splice(levelPosition, 1); + setSelectedLogLevels(currentLevels); + } else { + setSelectedLogLevels([...selectedLogLevels, level]); + } + }} + /> + </EuiFilterGroup> + </EuiFlexItem> + <DatePickerFlexItem grow={false}> + <EuiSuperDatePicker + showUpdateButton={false} + start={dateRange.startExpression} + end={dateRange.endExpression} + onTimeChange={({ start, end }) => { + tryUpdateDateRange({ + from: start, + to: end, + }); + }} + /> + </DatePickerFlexItem> + <EuiFlexItem grow={false}> + <RedirectAppLinks application={application}> + <EuiButtonEmpty href={viewInLogsUrl} iconType="popout" flush="both"> + <FormattedMessage + id="xpack.fleet.agentLogs.openInLogsUiLinkText" + defaultMessage="Open in Logs" + /> + </EuiButtonEmpty> + </RedirectAppLinks> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem> + <EuiPanel paddingSize="none"> + <LogStream + height="100%" + startTimestamp={dateRange.startTimestamp} + endTimestamp={dateRange.endTimestamp} + query={logStreamQuery} + /> + </EuiPanel> + </EuiFlexItem> + </WrapperFlexGroup> + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx new file mode 100644 index 00000000000000..ae2385d7142192 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + QueryStringInput, + IFieldType, +} from '../../../../../../../../../../../src/plugins/data/public'; +import { useStartServices } from '../../../../../hooks'; +import { + AGENT_LOG_INDEX_PATTERN, + AGENT_ID_FIELD, + DATASET_FIELD, + LOG_LEVEL_FIELD, +} from './constants'; + +const EXCLUDED_FIELDS = [AGENT_ID_FIELD.name, DATASET_FIELD.name, LOG_LEVEL_FIELD.name]; + +export const LogQueryBar: React.FunctionComponent<{ + query: string; + isQueryValid: boolean; + onUpdateQuery: (query: string, runQuery?: boolean) => void; +}> = memo(({ query, isQueryValid, onUpdateQuery }) => { + const { data } = useStartServices(); + const [indexPatternFields, setIndexPatternFields] = useState<IFieldType[]>(); + + useEffect(() => { + const fetchFields = async () => { + try { + const fields = ( + ((await data.indexPatterns.getFieldsForWildcard({ + pattern: AGENT_LOG_INDEX_PATTERN, + })) as IFieldType[]) || [] + ).filter((field) => { + return !EXCLUDED_FIELDS.includes(field.name); + }); + setIndexPatternFields(fields); + } catch (err) { + setIndexPatternFields(undefined); + } + }; + fetchFields(); + }, [data.indexPatterns]); + + return ( + <QueryStringInput + indexPatterns={ + indexPatternFields + ? [ + { + title: AGENT_LOG_INDEX_PATTERN, + fields: indexPatternFields, + }, + ] + : [] + } + query={{ + query, + language: 'kuery', + }} + isInvalid={!isQueryValid} + disableAutoFocus={true} + placeholder={i18n.translate('xpack.fleet.agentLogs.searchPlaceholderText', { + defaultMessage: 'Search logs…', + })} + onChange={(newQuery) => { + onUpdateQuery(newQuery.query as string); + }} + onSubmit={(newQuery) => { + onUpdateQuery(newQuery.query as string, true); + }} + /> + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/helper.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/helper.ts deleted file mode 100644 index b512ca230080d3..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/helper.ts +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { AgentMetadata } from '../../../../types'; - -export function flattenMetadata(metadata: AgentMetadata) { - return Object.entries(metadata).reduce((acc, [key, value]) => { - if (typeof value === 'string') { - acc[key] = value; - - return acc; - } - - Object.entries(flattenMetadata(value)).forEach(([flattenedKey, flattenedValue]) => { - acc[`${key}.${flattenedKey}`] = flattenedValue; - }); - - return acc; - }, {} as { [k: string]: string }); -} -export function unflattenMetadata(flattened: { [k: string]: string }) { - const metadata: AgentMetadata = {}; - - Object.entries(flattened).forEach(([flattenedKey, flattenedValue]) => { - const keyParts = flattenedKey.split('.'); - const lastKey = keyParts.pop(); - - if (!lastKey) { - throw new Error('Invalid metadata'); - } - - let metadataPart = metadata; - keyParts.forEach((keyPart) => { - if (!metadataPart[keyPart]) { - metadataPart[keyPart] = {}; - } - - metadataPart = metadataPart[keyPart] as AgentMetadata; - }); - metadataPart[lastKey] = flattenedValue; - }); - - return metadata; -} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts index 8e6ddd09593582..128f803bb2f2e1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts @@ -3,6 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { AgentEventsTable } from './agent_events_table'; +export { AgentLogs } from './agent_logs'; export { AgentDetailsActionMenu } from './actions_menu'; export { AgentDetailsContent } from './agent_details'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_flyout.tsx deleted file mode 100644 index f808f4ade107b5..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_flyout.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiTitle, - EuiSpacer, - EuiDescriptionList, - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiHorizontalRule, -} from '@elastic/eui'; -import { MetadataForm } from './metadata_form'; -import { Agent } from '../../../../types'; -import { flattenMetadata } from './helper'; - -interface Props { - agent: Agent; - flyout: { hide: () => void }; -} - -export const AgentMetadataFlyout: React.FunctionComponent<Props> = ({ agent, flyout }) => { - const mapMetadata = (obj: { [key: string]: string } | undefined) => { - return Object.keys(obj || {}).map((key) => ({ - title: key, - description: obj ? obj[key] : '', - })); - }; - - const localItems = mapMetadata(flattenMetadata(agent.local_metadata)); - const userProvidedItems = mapMetadata(flattenMetadata(agent.user_provided_metadata)); - - return ( - <EuiFlyout onClose={() => flyout.hide()} size="s" aria-labelledby="flyoutTitle"> - <EuiFlyoutHeader hasBorder> - <EuiTitle size="m"> - <h2 id="flyoutTitle"> - <FormattedMessage - id="xpack.fleet.agentDetails.metadataSectionTitle" - defaultMessage="Metadata" - /> - </h2> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.fleet.agentDetails.localMetadataSectionSubtitle" - defaultMessage="Local metadata" - /> - </h3> - </EuiTitle> - <EuiHorizontalRule /> - <EuiDescriptionList type="column" compressed listItems={localItems} /> - <EuiSpacer size="xxl" /> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.fleet.agentDetails.userProvidedMetadataSectionSubtitle" - defaultMessage="User provided metadata" - /> - </h3> - </EuiTitle> - <EuiHorizontalRule /> - <EuiDescriptionList type="column" compressed listItems={userProvidedItems} /> - <EuiSpacer size="m" /> - - <MetadataForm agent={agent} /> - </EuiFlyoutBody> - </EuiFlyout> - ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_form.tsx deleted file mode 100644 index fd8de709c172a6..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/metadata_form.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiPopover, - EuiFormRow, - EuiButton, - EuiFlexItem, - EuiFieldText, - EuiFlexGroup, - EuiForm, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { AxiosError } from 'axios'; -import { useAgentRefresh } from '../hooks'; -import { useInput, sendRequest } from '../../../../hooks'; -import { Agent } from '../../../../types'; -import { agentRouteService } from '../../../../services'; -import { flattenMetadata, unflattenMetadata } from './helper'; - -function useAddMetadataForm(agent: Agent, done: () => void) { - const refreshAgent = useAgentRefresh(); - const keyInput = useInput(); - const valueInput = useInput(); - const [state, setState] = useState<{ - isLoading: boolean; - error: null | string; - }>({ - isLoading: false, - error: null, - }); - - function clearInputs() { - keyInput.clear(); - valueInput.clear(); - } - - function setError(error: AxiosError) { - setState({ - isLoading: false, - error: error.response && error.response.data ? error.response.data.message : error.message, - }); - } - - async function success() { - await refreshAgent(); - setState({ - isLoading: false, - error: null, - }); - clearInputs(); - done(); - } - - return { - state, - onSubmit: async (e: React.FormEvent | React.MouseEvent) => { - e.preventDefault(); - setState({ - ...state, - isLoading: true, - }); - - const metadata = unflattenMetadata({ - ...flattenMetadata(agent.user_provided_metadata), - [keyInput.value]: valueInput.value, - }); - - try { - const { error } = await sendRequest({ - path: agentRouteService.getUpdatePath(agent.id), - method: 'put', - body: JSON.stringify({ - user_provided_metadata: metadata, - }), - }); - - if (error) { - throw error; - } - await success(); - } catch (error) { - setError(error); - } - }, - inputs: { - keyInput, - valueInput, - }, - }; -} - -export const MetadataForm: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { - const [isOpen, setOpen] = useState(false); - - const form = useAddMetadataForm(agent, () => { - setOpen(false); - }); - const { keyInput, valueInput } = form.inputs; - - const button = ( - <EuiButtonEmpty onClick={() => setOpen(true)} color={'text'}> - <FormattedMessage id="xpack.fleet.metadataForm.addButton" defaultMessage="+ Add metadata" /> - </EuiButtonEmpty> - ); - return ( - <> - <EuiPopover - id="trapFocus" - ownFocus - button={button} - isOpen={isOpen} - closePopover={() => setOpen(false)} - initialFocus="[id=fleet-details-metadata-form]" - > - <form onSubmit={form.onSubmit}> - <EuiForm error={form.state.error} isInvalid={form.state.error !== null}> - <EuiFlexGroup> - <EuiFlexItem> - <EuiFormRow - id="fleet-details-metadata-form" - label={i18n.translate('xpack.fleet.metadataForm.keyLabel', { - defaultMessage: 'Key', - })} - > - <EuiFieldText required={true} {...keyInput.props} /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem> - <EuiFormRow - label={i18n.translate('xpack.fleet.metadataForm.valueLabel', { - defaultMessage: 'Value', - })} - > - <EuiFieldText required={true} {...valueInput.props} /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiFormRow hasEmptyLabelSpace> - <EuiButton isLoading={form.state.isLoading} type={'submit'}> - <FormattedMessage - id="xpack.fleet.metadataForm.submitButtonText" - defaultMessage="Add" - /> - </EuiButton> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGroup> - </EuiForm> - </form> - </EuiPopover> - </> - ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/type_labels.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/type_labels.tsx deleted file mode 100644 index dbe18ab3337368..00000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/type_labels.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AgentEvent } from '../../../../types'; - -export const TYPE_LABEL: { [key in AgentEvent['type']]: JSX.Element } = { - STATE: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventType.stateLabel" defaultMessage="State" /> - </EuiBadge> - ), - ERROR: ( - <EuiBadge color="danger"> - <FormattedMessage id="xpack.fleet.agentEventType.errorLabel" defaultMessage="Error" /> - </EuiBadge> - ), - ACTION_RESULT: ( - <EuiBadge color="secondary"> - <FormattedMessage - id="xpack.fleet.agentEventType.actionResultLabel" - defaultMessage="Action result" - /> - </EuiBadge> - ), - ACTION: ( - <EuiBadge color="primary"> - <FormattedMessage id="xpack.fleet.agentEventType.actionLabel" defaultMessage="Action" /> - </EuiBadge> - ), -}; - -export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { - RUNNING: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventSubtype.runningLabel" defaultMessage="Running" /> - </EuiBadge> - ), - STARTING: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.startingLabel" - defaultMessage="Starting" - /> - </EuiBadge> - ), - IN_PROGRESS: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.inProgressLabel" - defaultMessage="In progress" - /> - </EuiBadge> - ), - CONFIG: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventSubtype.policyLabel" defaultMessage="Policy" /> - </EuiBadge> - ), - FAILED: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventSubtype.failedLabel" defaultMessage="Failed" /> - </EuiBadge> - ), - STOPPING: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.stoppingLabel" - defaultMessage="Stopping" - /> - </EuiBadge> - ), - STOPPED: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventSubtype.stoppedLabel" defaultMessage="Stopped" /> - </EuiBadge> - ), - DEGRADED: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.degradedLabel" - defaultMessage="Degraded" - /> - </EuiBadge> - ), - DATA_DUMP: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.dataDumpLabel" - defaultMessage="Data dump" - /> - </EuiBadge> - ), - ACKNOWLEDGED: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.acknowledgedLabel" - defaultMessage="Acknowledged" - /> - </EuiBadge> - ), - UPDATING: ( - <EuiBadge color="hollow"> - <FormattedMessage - id="xpack.fleet.agentEventSubtype.updatingLabel" - defaultMessage="Updating" - /> - </EuiBadge> - ), - UNKNOWN: ( - <EuiBadge color="hollow"> - <FormattedMessage id="xpack.fleet.agentEventSubtype.unknownLabel" defaultMessage="Unknown" /> - </EuiBadge> - ), -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx index 7d60ae23deac6f..f3714bbb532236 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx @@ -28,13 +28,13 @@ import { useGetOneAgentPolicy, useLink, useBreadcrumbs, - useCore, + useStartServices, useKibanaVersion, } from '../../../hooks'; import { WithHeaderLayout } from '../../../layouts'; import { AgentHealth } from '../components'; import { AgentRefreshContext } from './hooks'; -import { AgentEventsTable, AgentDetailsActionMenu, AgentDetailsContent } from './components'; +import { AgentLogs, AgentDetailsActionMenu, AgentDetailsContent } from './components'; import { useIntraAppState } from '../../../hooks/use_intra_app_state'; import { isAgentUpgradeable } from '../../../services'; @@ -67,7 +67,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { const { application: { navigateToApp }, - } = useCore(); + } = useStartServices(); const routeState = useIntraAppState<AgentDetailsReassignPolicyAction>(); const queryParams = new URLSearchParams(useLocation().search); const openReassignFlyoutOpenByDefault = queryParams.get('openReassignFlyout') === 'true'; @@ -223,21 +223,21 @@ export const AgentDetailsPage: React.FunctionComponent = () => { const headerTabs = useMemo(() => { return [ - { - id: 'activity_log', - name: i18n.translate('xpack.fleet.agentDetails.subTabs.activityLogTab', { - defaultMessage: 'Activity log', - }), - href: getHref('fleet_agent_details', { agentId, tabId: 'activity' }), - isSelected: !tabId || tabId === 'activity', - }, { id: 'details', name: i18n.translate('xpack.fleet.agentDetails.subTabs.detailsTab', { defaultMessage: 'Agent details', }), href: getHref('fleet_agent_details', { agentId, tabId: 'details' }), - isSelected: tabId === 'details', + isSelected: !tabId || tabId === 'details', + }, + { + id: 'logs', + name: i18n.translate('xpack.fleet.agentDetails.subTabs.logsTab', { + defaultMessage: 'Logs', + }), + href: getHref('fleet_agent_details', { agentId, tabId: 'logs' }), + isSelected: tabId === 'logs', }, ]; }, [getHref, agentId, tabId]); @@ -305,15 +305,15 @@ const AgentDetailsPageContent: React.FunctionComponent<{ return ( <Switch> <Route - path={PAGE_ROUTING_PATHS.fleet_agent_details_details} + path={PAGE_ROUTING_PATHS.fleet_agent_details_logs} render={() => { - return <AgentDetailsContent agent={agent} agentPolicy={agentPolicy} />; + return <AgentLogs agent={agent} />; }} /> <Route - path={PAGE_ROUTING_PATHS.fleet_agent_details_events} + path={PAGE_ROUTING_PATHS.fleet_agent_details} render={() => { - return <AgentEventsTable agent={agent} />; + return <AgentDetailsContent agent={agent} agentPolicy={agentPolicy} />; }} /> </Switch> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx index 758497607c057e..b90758335dc75a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; import { SO_SEARCH_LIMIT } from '../../../../constants'; import { AgentPolicy, GetEnrollmentAPIKeysResponse } from '../../../../types'; -import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks'; +import { sendGetEnrollmentAPIKeys, useStartServices } from '../../../../hooks'; import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; type Props = { @@ -27,7 +27,7 @@ type Props = { ); export const EnrollmentStepAgentPolicy: React.FC<Props> = (props) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const { withKeySelection, agentPolicies, onAgentPolicyChange } = props; const onKeyChange = props.withKeySelection && props.onKeyChange; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 656493e31e5f56..840e47c5cd1f78 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../../types'; import { useGetOneEnrollmentAPIKey, - useCore, + useStartServices, useGetSettings, useLink, useFleetStatus, @@ -26,7 +26,7 @@ interface Props { export const ManagedInstructions = React.memo<Props>(({ agentPolicies }) => { const { getHref } = useLink(); - const core = useCore(); + const core = useStartServices(); const fleetStatus = useFleetStatus(); const [selectedAPIKeyId, setSelectedAPIKeyId] = useState<string | undefined>(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx index a2daf2d10c2715..da2bb8adf1b35d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -21,7 +21,7 @@ import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../../types'; -import { useCore, useLink, sendGetOneAgentPolicyFull } from '../../../../hooks'; +import { useStartServices, useLink, sendGetOneAgentPolicyFull } from '../../../../hooks'; import { DownloadStep, AgentPolicySelectionStep } from './steps'; import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../../services'; @@ -33,7 +33,7 @@ const RUN_INSTRUCTIONS = './elastic-agent install'; export const StandaloneInstructions = React.memo<Props>(({ agentPolicies }) => { const { getHref } = useLink(); - const core = useCore(); + const core = useStartServices(); const { notifications } = core; const [selectedPolicyId, setSelectedPolicyId] = useState<string | undefined>(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx index 46e291e73fa786..90726b54d283ac 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_flyout/index.tsx @@ -25,7 +25,7 @@ import { Agent } from '../../../../types'; import { sendPutAgentReassign, sendPostBulkAgentReassign, - useCore, + useStartServices, useGetAgentPolicies, } from '../../../../hooks'; import { AgentPolicyPackageBadges } from '../agent_policy_package_badges'; @@ -39,7 +39,7 @@ export const AgentReassignAgentPolicyFlyout: React.FunctionComponent<Props> = ({ onClose, agents, }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const isSingleAgent = Array.isArray(agents) && agents.length === 1; const [selectedAgentPolicyId, setSelectedAgentPolicyId] = useState<string | undefined>( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx index 1b3935a86f65c4..180ad5e4953b82 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx @@ -8,7 +8,11 @@ import { i18n } from '@kbn/i18n'; import { EuiConfirmModal, EuiOverlayMask, EuiFormFieldset, EuiCheckbox } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; -import { sendPostAgentUnenroll, sendPostBulkAgentUnenroll, useCore } from '../../../../hooks'; +import { + sendPostAgentUnenroll, + sendPostBulkAgentUnenroll, + useStartServices, +} from '../../../../hooks'; interface Props { onClose: () => void; @@ -23,7 +27,7 @@ export const AgentUnenrollAgentModal: React.FunctionComponent<Props> = ({ agentCount, useForceUnenroll, }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [forceUnenroll, setForceUnenroll] = useState<boolean>(useForceUnenroll || false); const [isSubmitting, setIsSubmitting] = useState(false); const isSingleAgent = Array.isArray(agents) && agents.length === 1; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index 43ad7208c3d810..6b7fca9e086aa2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -14,7 +14,11 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; -import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useCore } from '../../../../hooks'; +import { + sendPostAgentUpgrade, + sendPostBulkAgentUpgrade, + useStartServices, +} from '../../../../hooks'; interface Props { onClose: () => void; @@ -29,7 +33,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<Props> = ({ agentCount, version, }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [isSubmitting, setIsSubmitting] = useState(false); const isSingleAgent = Array.isArray(agents) && agents.length === 1; async function onSubmit() { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx index 78e8be4679dc3c..ed607e361bd6e5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx @@ -22,14 +22,14 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../../types'; -import { useInput, useCore, sendRequest } from '../../../../hooks'; +import { useInput, useStartServices, sendRequest } from '../../../../hooks'; import { enrollmentAPIKeyRouteService } from '../../../../services'; function useCreateApiKeyForm( policyIdDefaultValue: string | undefined, onSuccess: (keyId: string) => void ) { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [isLoading, setIsLoading] = useState(false); const apiKeyNameInput = useInput(''); const policyIdInput = useInput(policyIdDefaultValue); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx index 7e5d07b2319d30..71cd417a256c37 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx @@ -26,7 +26,7 @@ import { useGetEnrollmentAPIKeys, useGetAgentPolicies, sendGetOneEnrollmentAPIKey, - useCore, + useStartServices, sendDeleteOneEnrollmentAPIKey, } from '../../../hooks'; import { EnrollmentAPIKey } from '../../../types'; @@ -35,7 +35,7 @@ import { NewEnrollmentTokenFlyout } from './components/new_enrollment_key_flyout import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal'; const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [state, setState] = useState<'VISIBLE' | 'HIDDEN' | 'LOADING'>('HIDDEN'); const [key, setKey] = useState<string | undefined>(); @@ -106,7 +106,7 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: apiKey, refresh, }) => { - const { notifications } = useCore(); + const { notifications } = useStartServices(); const [state, setState] = useState<'CONFIRM_VISIBLE' | 'CONFIRM_HIDDEN'>('CONFIRM_HIDDEN'); const onCancel = () => setState('CONFIRM_HIDDEN'); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/setup_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/setup_page/index.tsx index 60ee791ace5ebc..8fee44018f0a05 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/setup_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/setup_page/index.tsx @@ -22,7 +22,7 @@ import { EuiCodeBlock, EuiLink, } from '@elastic/eui'; -import { useCore, sendPostFleetSetup } from '../../../hooks'; +import { useStartServices, sendPostFleetSetup } from '../../../hooks'; import { WithoutHeaderLayout } from '../../../layouts'; import { GetFleetStatusResponse } from '../../../types'; @@ -53,7 +53,7 @@ export const SetupPage: React.FunctionComponent<{ missingRequirements: GetFleetStatusResponse['missing_requirements']; }> = ({ refresh, missingRequirements }) => { const [isFormLoading, setIsFormLoading] = useState<boolean>(false); - const core = useCore(); + const core = useStartServices(); const onSubmit = async () => { setIsFormLoading(true); 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 533c2736811220..c614518c1930bb 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 @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { DataStream } from '../../../types'; import { WithHeaderLayout } from '../../../layouts'; -import { useGetDataStreams, useStartDeps, usePagination, useBreadcrumbs } from '../../../hooks'; +import { useGetDataStreams, useStartServices, usePagination, useBreadcrumbs } from '../../../hooks'; import { PackageIcon } from '../../../components/package_icon'; import { DataStreamRowActions } from './components/data_stream_row_actions'; @@ -59,7 +59,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { const { data: { fieldFormats }, - } = useStartDeps(); + } = useStartServices(); const { pagination, pageSizeOptions } = usePagination(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx index 7004a602627c1c..8ced0734a39679 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx @@ -12,8 +12,9 @@ import { Loading } from '../../../components'; const PanelWrapper = styled.div` // NOTE: changes to the width here will impact navigation tabs page layout under integration package details width: ${(props) => - parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.spacerSizes.xl) * 2}px; + parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; height: 1px; + z-index: 1; `; const Panel = styled(EuiPanel)` diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_links.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_links.tsx index a453a7f2e28cb8..3d2babae8eb2ea 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_links.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_links.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useCore } from '../../../hooks/use_core'; +import { useStartServices } from '../../../hooks/use_core'; import { PLUGIN_ID } from '../../../constants'; import { epmRouteService } from '../../../services'; @@ -11,7 +11,7 @@ const removeRelativePath = (relativePath: string): string => new URL(relativePath, 'http://example.com').pathname; export function useLinks() { - const { http } = useCore(); + const { http } = useStartServices(); return { toAssets: (path: string) => http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`), toImage: (path: string) => http.basePath.prepend(epmRouteService.getFilePath(path)), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.scss b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.scss new file mode 100644 index 00000000000000..e8366d99b63916 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.scss @@ -0,0 +1,5 @@ +@import '@elastic/eui/src/global_styling/variables/_size.scss'; + +.fleet__epm__shiftNavTabs { + margin-left: $euiSize * 6 + $euiSizeXL * 2 + $euiSizeL; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index 2535a53589bd97..0e72693db9e2d0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -28,13 +28,13 @@ import { useLink, useCapabilities, } from '../../../../hooks'; -import { WithHeaderLayout } from '../../../../layouts'; +import { WithHeaderLayout, WithHeaderLayoutProps } from '../../../../layouts'; import { useSetPackageInstallStatus } from '../../hooks'; import { IconPanel, LoadingIconPanel } from '../../components/icon_panel'; import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from '../../components/release_badge'; import { UpdateIcon } from '../../components/icons'; import { Content } from './content'; -import { WithHeaderLayoutProps } from '../../../../layouts/with_header'; +import './index.scss'; export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; @@ -55,16 +55,6 @@ const PanelDisplayNames: Record<DetailViewPanelName, string> = { }), }; -const DetailWrapper = styled.div` - // Class name here is in sync with 'PanelWrapper' in 'IconPanel' component - .shiftNavTabs { - margin-left: ${(props) => - parseFloat(props.theme.eui.euiSize) * 6 + - parseFloat(props.theme.eui.spacerSizes.xl) * 2 + - parseFloat(props.theme.eui.spacerSizes.l)}px; - } -`; - const Divider = styled.div` width: 0; height: 100%; @@ -265,31 +255,29 @@ export function Detail() { }, [getHref, packageInfo, packageInfoData?.response?.status, panel]); return ( - <DetailWrapper> - <WithHeaderLayout - leftColumn={headerLeftContent} - rightColumn={headerRightContent} - rightColumnGrow={false} - tabs={tabs} - tabsClassName={'shiftNavTabs'} - > - {packageInfo ? <Breadcrumbs packageTitle={packageInfo.title} /> : null} - {packageInfoError ? ( - <Error - title={ - <FormattedMessage - id="xpack.fleet.epm.loadingIntegrationErrorTitle" - defaultMessage="Error loading integration details" - /> - } - error={packageInfoError} - /> - ) : isLoading || !packageInfo ? ( - <Loading /> - ) : ( - <Content {...packageInfo} panel={panel} /> - )} - </WithHeaderLayout> - </DetailWrapper> + <WithHeaderLayout + leftColumn={headerLeftContent} + rightColumn={headerRightContent} + rightColumnGrow={false} + tabs={tabs} + tabsClassName="fleet__epm__shiftNavTabs" + > + {packageInfo ? <Breadcrumbs packageTitle={packageInfo.title} /> : null} + {packageInfoError ? ( + <Error + title={ + <FormattedMessage + id="xpack.fleet.epm.loadingIntegrationErrorTitle" + defaultMessage="Error loading integration details" + /> + } + error={packageInfoError} + /> + ) : isLoading || !packageInfo ? ( + <Loading /> + ) : ( + <Content {...packageInfo} panel={panel} /> + )} + </WithHeaderLayout> ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/home/header.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/home/header.tsx index e9704cd16b2192..b5fef901d123d0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/home/header.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/home/header.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useLinks } from '../../hooks'; -import { useCore } from '../../../../hooks'; +import { useStartServices } from '../../../../hooks'; export const HeroCopy = memo(() => { return ( @@ -43,7 +43,7 @@ const Illustration = styled(EuiImage)` export const HeroImage = memo(() => { const { toAssets } = useLinks(); - const { uiSettings } = useCore(); + const { uiSettings } = useStartServices(); const IS_DARK_THEME = uiSettings.get('theme:darkMode'); return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/overview/components/datastream_section.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/overview/components/datastream_section.tsx index 58f84e86713853..10f538b3112c65 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/overview/components/datastream_section.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/overview/components/datastream_section.tsx @@ -15,7 +15,7 @@ import { } from '@elastic/eui'; import { OverviewPanel } from './overview_panel'; import { OverviewStats } from './overview_stats'; -import { useLink, useGetDataStreams, useStartDeps } from '../../../hooks'; +import { useLink, useGetDataStreams, useStartServices } from '../../../hooks'; import { Loading } from '../../agents/components'; export const OverviewDatastreamSection: React.FC = () => { @@ -23,7 +23,7 @@ export const OverviewDatastreamSection: React.FC = () => { const datastreamRequest = useGetDataStreams(); const { data: { fieldFormats }, - } = useStartDeps(); + } = useStartServices(); const total = datastreamRequest.data?.data_streams?.length ?? 0; let sizeBytes = 0; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 7e523b3fa594a8..31b53f41b3a913 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -17,6 +17,7 @@ import { HomePublicPluginSetup, FeatureCatalogueCategory, } from '../../../../src/plugins/home/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { PLUGIN_ID, CheckPermissionsResponse, PostIngestSetupResponse } from '../common'; import { BASE_PATH } from './applications/fleet/constants'; @@ -58,10 +59,15 @@ export interface FleetStartDeps { data: DataPublicPluginStart; } +export interface FleetStartServices extends CoreStart, FleetStartDeps { + storage: Storage; +} + export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDeps, FleetStartDeps> { private config: FleetConfigType; private kibanaVersion: string; private extensions: UIExtensionsStorage = {}; + private storage = new Storage(localStorage); constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get<FleetConfigType>(); @@ -86,26 +92,23 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep title: i18n.translate('xpack.fleet.appTitle', { defaultMessage: 'Fleet' }), order: 9020, euiIconType: 'logoElastic', - async mount(params: AppMountParameters) { - const [coreStart, startDeps] = (await core.getStartServices()) as [ + mount: async (params: AppMountParameters) => { + const [coreStartServices, startDepsServices] = (await core.getStartServices()) as [ CoreStart, FleetStartDeps, FleetStart ]; - const { renderApp, teardownFleet } = await import('./applications/fleet/'); - const unmount = renderApp( - coreStart, - params, - deps, - startDeps, - config, - kibanaVersion, - extensions - ); + const startServices: FleetStartServices = { + ...coreStartServices, + ...startDepsServices, + storage: this.storage, + }; + const { renderApp, teardownFleet } = await import('./applications/fleet'); + const unmount = renderApp(startServices, params, config, kibanaVersion, extensions); return () => { unmount(); - teardownFleet(coreStart); + teardownFleet(startServices); }; }, }); diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 3d30acd3f8e01e..1fe7013944fd71 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -13,7 +13,13 @@ import { } from '../common'; export { default as apm } from 'elastic-apm-node'; -export { AgentService, ESIndexPatternService, getRegistryUrl, PackageService } from './services'; +export { + AgentService, + ESIndexPatternService, + getRegistryUrl, + PackageService, + AgentPolicyServiceInterface, +} from './services'; export { FleetSetupContract, FleetSetupDeps, FleetStartContract, ExternalCallback } from './plugin'; export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index c8aef287e4432d..91098c87c312a8 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -9,6 +9,7 @@ import { FleetAppContext } from './plugin'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { securityMock } from '../../security/server/mocks'; import { PackagePolicyServiceInterface } from './services/package_policy'; +import { AgentPolicyServiceInterface, AgentService } from './services'; export const createAppContextStartContractMock = (): FleetAppContext => { return { @@ -35,3 +36,28 @@ export const createPackagePolicyServiceMock = () => { update: jest.fn(), } as jest.Mocked<PackagePolicyServiceInterface>; }; + +/** + * Create mock AgentPolicyService + */ + +export const createMockAgentPolicyService = (): jest.Mocked<AgentPolicyServiceInterface> => { + return { + get: jest.fn(), + list: jest.fn(), + getDefaultAgentPolicyId: jest.fn(), + getFullAgentPolicy: jest.fn(), + }; +}; + +/** + * Creates a mock AgentService + */ +export const createMockAgentService = (): jest.Mocked<AgentService> => { + return { + getAgentStatusById: jest.fn(), + authenticateAgentWithAccessToken: jest.fn(), + getAgent: jest.fn(), + listAgents: jest.fn(), + }; +}; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index e4ed386802c3af..90fb34efd4817e 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -58,6 +58,8 @@ import { ESIndexPatternSavedObjectService, ESIndexPatternService, AgentService, + AgentPolicyServiceInterface, + agentPolicyService, packagePolicyService, PackageService, } from './services'; @@ -134,6 +136,7 @@ export interface FleetStartContract { * Services for Fleet's package policies */ packagePolicyService: typeof packagePolicyService; + agentPolicyService: AgentPolicyServiceInterface; /** * Register callbacks for inclusion in fleet API processing * @param args @@ -292,6 +295,12 @@ export class FleetPlugin getAgentStatusById, authenticateAgentWithAccessToken, }, + agentPolicyService: { + get: agentPolicyService.get, + list: agentPolicyService.list, + getDefaultAgentPolicyId: agentPolicyService.getDefaultAgentPolicyId, + getFullAgentPolicy: agentPolicyService.getFullAgentPolicy, + }, packagePolicyService, registerExternalCallback: (...args: ExternalCallback) => { return appContextService.addExternalCallback(...args); diff --git a/x-pack/plugins/fleet/server/services/epm/archive/cache.ts b/x-pack/plugins/fleet/server/services/epm/archive/cache.ts index 280c34744289e6..04aa1767b4f14a 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/cache.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/cache.ts @@ -20,8 +20,7 @@ export interface SharedKey { } type SharedKeyString = string; -type ArchiveFilelist = string[]; -const archiveFilelistCache: Map<SharedKeyString, ArchiveFilelist> = new Map(); +const archiveFilelistCache: Map<SharedKeyString, string[]> = new Map(); export const getArchiveFilelist = (keyArgs: SharedKey) => archiveFilelistCache.get(sharedKey(keyArgs)); @@ -46,6 +45,15 @@ export const getPackageInfo = (args: SharedKey) => { } }; +export const getArchivePackage = (args: SharedKey) => { + const packageInfo = getPackageInfo(args); + const paths = getArchiveFilelist(args); + return { + paths, + packageInfo, + }; +}; + export const setPackageInfo = ({ name, version, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 2d4a94a2332d67..3df2d39419ab89 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -7,10 +7,11 @@ import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'src/core/server'; import { isPackageLimited, installationStatuses } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; -import { ValueOf } from '../../../../common/types'; +import { ArchivePackage, InstallSource, RegistryPackage, ValueOf } from '../../../../common/types'; import { Installation, InstallationStatus, PackageInfo, KibanaAssetType } from '../../../types'; import * as Registry from '../registry'; import { createInstallableFrom, isRequiredPackage } from './index'; +import { getArchivePackage } from '../archive'; export { fetchFile as getFile, SearchParams } from '../registry'; @@ -109,23 +110,53 @@ export async function getPackageInfo(options: { pkgVersion: string; }): Promise<PackageInfo> { const { savedObjectsClient, pkgName, pkgVersion } = options; - const [savedObject, latestPackage, { paths: assets, packageInfo: item }] = await Promise.all([ + const [savedObject, latestPackage] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), Registry.fetchFindLatestPackage(pkgName), - Registry.getRegistryPackage(pkgName, pkgVersion), ]); - // add properties that aren't (or aren't yet) on Registry response + const getPackageRes = await getPackageFromSource({ + pkgName, + pkgVersion, + pkgInstallSource: savedObject?.attributes.install_source, + }); + const paths = getPackageRes.paths; + const packageInfo = getPackageRes.packageInfo; + + // add properties that aren't (or aren't yet) on the package const updated = { - ...item, + ...packageInfo, latestVersion: latestPackage.version, - title: item.title || nameAsTitle(item.name), - assets: Registry.groupPathsByService(assets || []), + title: packageInfo.title || nameAsTitle(packageInfo.name), + assets: Registry.groupPathsByService(paths || []), removable: !isRequiredPackage(pkgName), }; return createInstallableFrom(updated, savedObject); } +// gets package from install_source if it exists otherwise gets from registry +export async function getPackageFromSource(options: { + pkgName: string; + pkgVersion: string; + pkgInstallSource?: InstallSource; +}): Promise<{ paths: string[] | undefined; packageInfo: RegistryPackage | ArchivePackage }> { + const { pkgName, pkgVersion, pkgInstallSource } = options; + // TODO: Check package storage before checking registry + let res; + if (pkgInstallSource === 'upload') { + res = getArchivePackage({ + name: pkgName, + version: pkgVersion, + installSource: pkgInstallSource, + }); + if (!res.packageInfo) + throw new Error(`installed package ${pkgName}-${pkgVersion} does not exist in cache`); + } else { + res = await Registry.getRegistryPackage(pkgName, pkgVersion); + } + return res; +} + export async function getInstallationObject(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 7a62c307973c2a..d9015c51955362 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -9,6 +9,7 @@ import { AgentStatus, Agent, EsAssetReference } from '../types'; import * as settingsService from './settings'; import { getAgent, listAgents } from './agents'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; +import { agentPolicyService } from './agent_policy'; export { getRegistryUrl } from './epm/registry/registry_url'; @@ -59,6 +60,13 @@ export interface AgentService { listAgents: typeof listAgents; } +export interface AgentPolicyServiceInterface { + get: typeof agentPolicyService['get']; + list: typeof agentPolicyService['list']; + getDefaultAgentPolicyId: typeof agentPolicyService['getDefaultAgentPolicyId']; + getFullAgentPolicy: typeof agentPolicyService['getFullAgentPolicy']; +} + // Saved object services export { agentPolicyService } from './agent_policy'; export { packagePolicyService } from './package_policy'; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 9ca6db40a30549..32812f19a2541e 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -6,12 +6,7 @@ import { encode } from 'rison-node'; import { SearchResponse } from 'elasticsearch'; -import { - FetchData, - FetchDataParams, - HasData, - LogsFetchDataResponse, -} from '../../../observability/public'; +import { FetchData, FetchDataParams, LogsFetchDataResponse } from '../../../observability/public'; import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; @@ -38,9 +33,7 @@ interface LogParams { type StatsAndSeries = Pick<LogsFetchDataResponse, 'stats' | 'series'>; -export function getLogsHasDataFetcher( - getStartServices: InfraClientCoreSetup['getStartServices'] -): HasData { +export function getLogsHasDataFetcher(getStartServices: InfraClientCoreSetup['getStartServices']) { return async () => { const [core] = await getStartServices(); const sourceStatus = await callFetchLogSourceStatusAPI(DEFAULT_SOURCE_ID, core.http.fetch); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index b56ede19743939..14785f64cffac0 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -9,6 +9,7 @@ import moment from 'moment'; import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertStates, InventoryMetricConditions } from './types'; +import { ResolvedActionGroup } from '../../../../../alerts/common'; import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraBackendLibs } from '../../infra_types'; @@ -18,6 +19,7 @@ import { buildErrorAlertReason, buildFiredAlertReason, buildNoDataAlertReason, + buildRecoveredAlertReason, stateToAlertMessage, } from '../common/messages'; import { evaluateCondition } from './evaluate_condition'; @@ -56,6 +58,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const inventoryItems = Object.keys(first(results)!); for (const item of inventoryItems) { const alertInstance = services.alertInstanceFactory(`${item}`); + const prevState = alertInstance.getState(); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => // Grab the result of the most recent bucket @@ -80,6 +83,10 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = reason = results .map((result) => buildReasonWithVerboseMetricName(result[item], buildFiredAlertReason)) .join('\n'); + } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { + reason = results + .map((result) => buildReasonWithVerboseMetricName(result[item], buildRecoveredAlertReason)) + .join('\n'); } if (alertOnNoData) { if (nextState === AlertStates.NO_DATA) { @@ -95,7 +102,9 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = } } if (reason) { - alertInstance.scheduleActions(FIRED_ACTIONS.id, { + const actionGroupId = + nextState === AlertStates.OK ? ResolvedActionGroup.id : FIRED_ACTIONS.id; + alertInstance.scheduleActions(actionGroupId, { group: item, alertState: stateToAlertMessage[nextState], reason, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 3a52bb6b6ce710..b31afba8ac9cc3 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -6,6 +6,7 @@ import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { Comparator, AlertStates } from './types'; import * as mocks from './test_mocks'; +import { ResolvedActionGroup } from '../../../../../alerts/common'; import { AlertExecutorOptions } from '../../../../../alerts/server'; import { alertsMock, @@ -20,7 +21,7 @@ interface AlertTestInstance { state: any; } -let persistAlertInstances = false; // eslint-disable-line +let persistAlertInstances = false; describe('The metric threshold alert type', () => { describe('querying the entire infrastructure', () => { @@ -343,50 +344,49 @@ describe('The metric threshold alert type', () => { }); }); - // describe('querying a metric that later recovers', () => { - // const instanceID = '*'; - // const execute = (threshold: number[]) => - // executor({ - // - // services, - // params: { - // criteria: [ - // { - // ...baseCriterion, - // comparator: Comparator.GT, - // threshold, - // }, - // ], - // }, - // }); - // beforeAll(() => (persistAlertInstances = true)); - // afterAll(() => (persistAlertInstances = false)); + describe('querying a metric that later recovers', () => { + const instanceID = '*'; + const execute = (threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator: Comparator.GT, + threshold, + }, + ], + }, + }); + beforeAll(() => (persistAlertInstances = true)); + afterAll(() => (persistAlertInstances = false)); - // test('sends a recovery alert as soon as the metric recovers', async () => { - // await execute([0.5]); - // expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - // expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); - // await execute([2]); - // expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - // expect(getState(instanceID).alertState).toBe(AlertStates.OK); - // }); - // test('does not continue to send a recovery alert if the metric is still OK', async () => { - // await execute([2]); - // expect(mostRecentAction(instanceID)).toBe(undefined); - // expect(getState(instanceID).alertState).toBe(AlertStates.OK); - // await execute([2]); - // expect(mostRecentAction(instanceID)).toBe(undefined); - // expect(getState(instanceID).alertState).toBe(AlertStates.OK); - // }); - // test('sends a recovery alert again once the metric alerts and recovers again', async () => { - // await execute([0.5]); - // expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - // expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); - // await execute([2]); - // expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - // expect(getState(instanceID).alertState).toBe(AlertStates.OK); - // }); - // }); + test('sends a recovery alert as soon as the metric recovers', async () => { + await execute([0.5]); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); + await execute([2]); + expect(mostRecentAction(instanceID).id).toBe(ResolvedActionGroup.id); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + }); + test('does not continue to send a recovery alert if the metric is still OK', async () => { + await execute([2]); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + await execute([2]); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + }); + test('sends a recovery alert again once the metric alerts and recovers again', async () => { + await execute([0.5]); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); + await execute([2]); + expect(mostRecentAction(instanceID).id).toBe(ResolvedActionGroup.id); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + }); + }); describe('querying a metric with a percentage metric', () => { const instanceID = '*'; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 4dec552c5bd6c6..7c3918c37ebbf5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -6,12 +6,14 @@ import { first, last } from 'lodash'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; +import { ResolvedActionGroup } from '../../../../../alerts/common'; import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InfraBackendLibs } from '../../infra_types'; import { buildErrorAlertReason, buildFiredAlertReason, buildNoDataAlertReason, + buildRecoveredAlertReason, stateToAlertMessage, } from '../common/messages'; import { createFormatter } from '../../../../common/formatters'; @@ -40,6 +42,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => const groups = Object.keys(first(alertResults)!); for (const group of groups) { const alertInstance = services.alertInstanceFactory(`${group}`); + const prevState = alertInstance.getState(); // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => @@ -64,6 +67,10 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => reason = alertResults .map((result) => buildFiredAlertReason(formatAlertResult(result[group]))) .join('\n'); + } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { + reason = alertResults + .map((result) => buildRecoveredAlertReason(formatAlertResult(result[group]))) + .join('\n'); } if (alertOnNoData) { if (nextState === AlertStates.NO_DATA) { @@ -81,7 +88,9 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => if (reason) { const firstResult = first(alertResults); const timestamp = (firstResult && firstResult[group].timestamp) ?? moment().toISOString(); - alertInstance.scheduleActions(FIRED_ACTIONS.id, { + const actionGroupId = + nextState === AlertStates.OK ? ResolvedActionGroup.id : FIRED_ACTIONS.id; + alertInstance.scheduleActions(actionGroupId, { group, alertState: stateToAlertMessage[nextState], reason, @@ -98,7 +107,6 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => }); } - // Future use: ability to fetch display current alert state alertInstance.replaceState({ alertState: nextState, }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 0af8e01d7290d1..cf3752e649600b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -410,7 +410,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); - expect(error).not.toBeDefined(); + expect(error).toBeUndefined(); }); it('returns undefined if the metric dimension is defined', () => { @@ -427,7 +427,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); - expect(error).not.toBeDefined(); + expect(error).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 647c0f3ac9cca7..0c96fc45de1284 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -134,7 +134,7 @@ export const validateDatasourceAndVisualization = ( ? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI) : undefined; - if (datasourceValidationErrors || visualizationValidationErrors) { + if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) { return [...(datasourceValidationErrors || []), ...(visualizationValidationErrors || [])]; } return undefined; 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 00cb932a6d4e21..95aeedbd857cad 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 @@ -385,7 +385,7 @@ export const InnerVisualizationWrapper = ({ [dispatch] ); - if (localState.configurationValidationError) { + if (localState.configurationValidationError?.length) { let showExtraErrors = null; if (localState.configurationValidationError.length > 1) { if (localState.expandError) { @@ -445,7 +445,7 @@ export const InnerVisualizationWrapper = ({ ); } - if (localState.expressionBuildError) { + if (localState.expressionBuildError?.length) { return ( <EuiFlexGroup style={{ maxWidth: '100%' }} direction="column" alignItems="center"> <EuiFlexItem> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index cd196745f3315d..e5c05a1cf8c7a9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -419,7 +419,7 @@ export function DimensionEditor(props: DimensionEditorProps) { function getErrorMessage( selectedColumn: IndexPatternColumn | undefined, incompatibleSelectedOperationType: boolean, - input: 'none' | 'field' | undefined, + input: 'none' | 'field' | 'fullReference' | undefined, fieldInvalid: boolean ) { if (selectedColumn && incompatibleSelectedOperationType) { 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 b2edc61a56736d..2e57ecee860334 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 @@ -1054,6 +1054,7 @@ describe('IndexPatternDimensionEditorPanel', () => { indexPatternId: '1', columns: {}, columnOrder: [], + incompleteColumns: {}, }, }, }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts index 31fb5277d53ec4..817fdf637f0010 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts @@ -21,6 +21,8 @@ type Props = Pick< 'layerId' | 'columnId' | 'state' | 'filterOperations' >; +// TODO: the support matrix should be available outside of the dimension panel + // TODO: This code has historically been memoized, as a potentially performance // sensitive task. If we can add memoization without breaking the behavior, we should. export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/format_column.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/format_column.test.ts new file mode 100644 index 00000000000000..8d24ef4e86f19b --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/format_column.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Datatable, DatatableColumn } from 'src/plugins/expressions/public'; +import { functionWrapper } from 'src/plugins/expressions/common/expression_functions/specs/tests/utils'; +import { FormatColumnArgs, formatColumn } from './format_column'; + +describe('format_column', () => { + const fn: (input: Datatable, args: FormatColumnArgs) => Datatable = functionWrapper(formatColumn); + + let datatable: Datatable; + + beforeEach(() => { + datatable = { + type: 'datatable', + rows: [], + columns: [ + { + id: 'test', + name: 'test', + meta: { + type: 'number', + params: { + id: 'number', + }, + }, + }, + ], + }; + }); + + it('overwrites format', () => { + datatable.columns[0].meta.params = { id: 'myformatter', params: {} }; + const result = fn(datatable, { columnId: 'test', format: 'otherformatter' }); + expect(result.columns[0].meta.params).toEqual({ + id: 'otherformatter', + }); + }); + + it('overwrites format with well known pattern', () => { + datatable.columns[0].meta.params = { id: 'myformatter', params: {} }; + const result = fn(datatable, { columnId: 'test', format: 'number' }); + expect(result.columns[0].meta.params).toEqual({ + id: 'number', + params: { + pattern: '0,0.00', + }, + }); + }); + + it('uses number of decimals if provided', () => { + datatable.columns[0].meta.params = { id: 'myformatter', params: {} }; + const result = fn(datatable, { columnId: 'test', format: 'number', decimals: 5 }); + expect(result.columns[0].meta.params).toEqual({ + id: 'number', + params: { + pattern: '0,0.00000', + }, + }); + }); + + it('has special handling for 0 decimals', () => { + datatable.columns[0].meta.params = { id: 'myformatter', params: {} }; + const result = fn(datatable, { columnId: 'test', format: 'number', decimals: 0 }); + expect(result.columns[0].meta.params).toEqual({ + id: 'number', + params: { + pattern: '0,0', + }, + }); + }); + + describe('parent format', () => { + it('should ignore parent format if it is not specifying an id', () => { + const result = fn(datatable, { + columnId: 'test', + format: '', + parentFormat: JSON.stringify({ some: 'key' }), + }); + expect(result.columns[0].meta.params).toEqual(datatable.columns[0].meta.params); + }); + + it('set parent format with params', () => { + const result = fn(datatable, { + columnId: 'test', + format: '', + parentFormat: JSON.stringify({ id: 'wrapper', params: { wrapperParam: 123 } }), + }); + expect(result.columns[0].meta.params).toEqual({ + id: 'wrapper', + params: { + wrapperParam: 123, + id: 'number', + }, + }); + }); + + it('retain inner formatter params', () => { + datatable.columns[0].meta.params = { id: 'myformatter', params: { innerParam: 456 } }; + const result = fn(datatable, { + columnId: 'test', + format: '', + parentFormat: JSON.stringify({ id: 'wrapper', params: { wrapperParam: 123 } }), + }); + expect(result.columns[0].meta.params).toEqual({ + id: 'wrapper', + params: { + wrapperParam: 123, + id: 'myformatter', + params: { + innerParam: 456, + }, + }, + }); + }); + + it('overwrite existing wrapper param', () => { + datatable.columns[0].meta.params = { + id: 'wrapper', + params: { wrapperParam: 0, id: 'myformatter', params: { innerParam: 456 } }, + }; + const result = fn(datatable, { + columnId: 'test', + format: '', + parentFormat: JSON.stringify({ id: 'wrapper', params: { wrapperParam: 123 } }), + }); + expect(result.columns[0].meta.params).toEqual({ + id: 'wrapper', + params: { + wrapperParam: 123, + id: 'myformatter', + params: { + innerParam: 456, + }, + }, + }); + }); + + it('overwrites format with well known pattern including decimals', () => { + datatable.columns[0].meta.params = { + id: 'previousWrapper', + params: { id: 'myformatter', params: { innerParam: 456 } }, + }; + const result = fn(datatable, { + columnId: 'test', + format: 'number', + decimals: 5, + parentFormat: JSON.stringify({ id: 'wrapper', params: { wrapperParam: 123 } }), + }); + expect(result.columns[0].meta.params).toEqual({ + id: 'wrapper', + params: { + wrapperParam: 123, + id: 'number', + params: { + pattern: '0,0.00000', + }, + }, + }); + }); + }); + + it('does not touch other column meta data', () => { + const extraColumn: DatatableColumn = { id: 'test2', name: 'test2', meta: { type: 'number' } }; + datatable.columns.push(extraColumn); + const result = fn(datatable, { columnId: 'test', format: 'number' }); + expect(result.columns[1]).toEqual(extraColumn); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/format_column.ts b/x-pack/plugins/lens/public/indexpattern_datasource/format_column.ts index 1f337298a03adb..cc4d8db720ba23 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/format_column.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/format_column.ts @@ -10,7 +10,7 @@ import { DatatableColumn, } from 'src/plugins/expressions/public'; -interface FormatColumn { +export interface FormatColumnArgs { format: string; columnId: string; decimals?: number; @@ -50,7 +50,7 @@ export const supportedFormats: Record< export const formatColumn: ExpressionFunctionDefinition< 'lens_format_column', Datatable, - FormatColumn, + FormatColumnArgs, Datatable > = { name: 'lens_format_column', @@ -77,7 +77,7 @@ export const formatColumn: ExpressionFunctionDefinition< }, }, inputTypes: ['datatable'], - fn(input, { format, columnId, decimals, parentFormat }: FormatColumn) { + fn(input, { format, columnId, decimals, parentFormat }: FormatColumnArgs) { return { ...input, columns: input.columns.map((col) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 51d95245adb252..3cf9bdc3a92f17 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -13,9 +13,15 @@ import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { Ast } from '@kbn/interpreter/common'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { getFieldByNameFactory } from './pure_helpers'; +import { + operationDefinitionMap, + getErrorMessages, + createMockedReferenceOperation, +} from './operations'; jest.mock('./loader'); jest.mock('../id_generator'); +jest.mock('./operations'); const fieldsOne = [ { @@ -489,6 +495,56 @@ describe('IndexPattern Data Source', () => { expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); }); + + describe('references', () => { + beforeEach(() => { + // @ts-expect-error we are inserting an invalid type + operationDefinitionMap.testReference = createMockedReferenceOperation(); + + // @ts-expect-error we are inserting an invalid type + operationDefinitionMap.testReference.toExpression.mockReturnValue(['mock']); + }); + + afterEach(() => { + delete operationDefinitionMap.testReference; + }); + + it('should collect expression references and append them', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'cardinality', + }, + col2: { + label: 'Reference', + dataType: 'number', + isBucketed: false, + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + // @ts-expect-error we can't isolate just the reference type + expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); + expect(ast.chain[2]).toEqual('mock'); + }); + }); }); describe('#insertLayer', () => { @@ -599,11 +655,33 @@ describe('IndexPattern Data Source', () => { describe('getTableSpec', () => { it('should include col1', () => { - expect(publicAPI.getTableSpec()).toEqual([ - { - columnId: 'col1', + expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col1' }]); + }); + + it('should skip columns that are being referenced', () => { + publicAPI = indexPatternDatasource.getPublicAPI({ + state: { + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + // @ts-ignore this is too little information for a real column + col1: { + dataType: 'number', + }, + col2: { + // @ts-expect-error update once we have a reference operation outside tests + references: ['col1'], + }, + }, + }, + }, }, - ]); + layerId: 'first', + }); + + expect(publicAPI.getTableSpec()).toEqual([{ columnId: 'col2' }]); }); }); @@ -764,7 +842,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'document', + operationType: 'avg', sourceField: 'bytes', }, }, @@ -774,7 +852,7 @@ describe('IndexPattern Data Source', () => { }; expect( indexPatternDatasource.getErrorMessages(state as IndexPatternPrivateState) - ).not.toBeDefined(); + ).toBeUndefined(); }); it('should return no errors with layers with no columns', () => { @@ -792,7 +870,31 @@ describe('IndexPattern Data Source', () => { }, currentIndexPatternId: '1', }; - expect(indexPatternDatasource.getErrorMessages(state)).not.toBeDefined(); + expect(indexPatternDatasource.getErrorMessages(state)).toBeUndefined(); + }); + + it('should bubble up invalid configuration from operations', () => { + (getErrorMessages as jest.Mock).mockClear(); + (getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']); + const state: IndexPatternPrivateState = { + indexPatternRefs: [], + existingFields: {}, + isFirstExistenceFetch: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.getErrorMessages(state)).toEqual([ + { shortMessage: 'error 1', longMessage: '' }, + { shortMessage: 'error 2', longMessage: '' }, + ]); + expect(getErrorMessages).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 94f240058d6189..2c64431867df0f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -40,13 +40,13 @@ import { } from './indexpattern_suggestions'; import { - getInvalidFieldReferencesForLayer, - getInvalidReferences, + getInvalidFieldsForLayer, + getInvalidLayers, isDraggedField, normalizeOperationDataType, } from './utils'; import { LayerPanel } from './layerpanel'; -import { IndexPatternColumn } from './operations'; +import { IndexPatternColumn, getErrorMessages } from './operations'; import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -54,7 +54,7 @@ import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/pub import { mergeLayer } from './state_helpers'; import { Datasource, StateSetter } from '../index'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; -import { deleteColumn } from './operations'; +import { deleteColumn, isReferenced } from './operations'; import { FieldBasedIndexPatternColumn } from './operations/definitions/column_types'; import { Dragging } from '../drag_drop/providers'; @@ -325,7 +325,9 @@ export function getIndexPatternDatasource({ datasourceId: 'indexpattern', getTableSpec: () => { - return state.layers[layerId].columnOrder.map((colId) => ({ columnId: colId })); + return state.layers[layerId].columnOrder + .filter((colId) => !isReferenced(state.layers[layerId], colId)) + .map((colId) => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { const layer = state.layers[layerId]; @@ -349,10 +351,17 @@ export function getIndexPatternDatasource({ if (!state) { return; } - const invalidLayers = getInvalidReferences(state); + const invalidLayers = getInvalidLayers(state); + + const layerErrors = Object.values(state.layers).flatMap((layer) => + (getErrorMessages(layer) ?? []).map((message) => ({ + shortMessage: message, + longMessage: '', + })) + ); if (invalidLayers.length === 0) { - return; + return layerErrors.length ? layerErrors : undefined; } const realIndex = Object.values(state.layers) @@ -363,64 +372,69 @@ export function getIndexPatternDatasource({ } }) .filter(Boolean) as Array<[number, number]>; - const invalidFieldsPerLayer: string[][] = getInvalidFieldReferencesForLayer( + const invalidFieldsPerLayer: string[][] = getInvalidFieldsForLayer( invalidLayers, state.indexPatterns ); const originalLayersList = Object.keys(state.layers); - return realIndex.map(([filteredIndex, layerIndex]) => { - const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map( - (columnId) => { - const column = invalidLayers[filteredIndex].columns[ - columnId - ] as FieldBasedIndexPatternColumn; - return column.sourceField; - } - ); - - if (originalLayersList.length === 1) { - return { - shortMessage: i18n.translate( - 'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer', - { - defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.', + if (layerErrors.length || realIndex.length) { + return [ + ...layerErrors, + ...realIndex.map(([filteredIndex, layerIndex]) => { + const fieldsWithBrokenReferences: string[] = invalidFieldsPerLayer[filteredIndex].map( + (columnId) => { + const column = invalidLayers[filteredIndex].columns[ + columnId + ] as FieldBasedIndexPatternColumn; + return column.sourceField; + } + ); + + if (originalLayersList.length === 1) { + return { + shortMessage: i18n.translate( + 'xpack.lens.indexPattern.dataReferenceFailureShortSingleLayer', + { + defaultMessage: 'Invalid {fields, plural, one {reference} other {references}}.', + values: { + fields: fieldsWithBrokenReferences.length, + }, + } + ), + longMessage: i18n.translate( + 'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer', + { + defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`, + values: { + fields: fieldsWithBrokenReferences.join('", "'), + fieldsLength: fieldsWithBrokenReferences.length, + }, + } + ), + }; + } + return { + shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', { + defaultMessage: + 'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.', values: { - fields: fieldsWithBrokenReferences.length, + layer: layerIndex, + fieldsLength: fieldsWithBrokenReferences.length, }, - } - ), - longMessage: i18n.translate( - 'xpack.lens.indexPattern.dataReferenceFailureLongSingleLayer', - { - defaultMessage: `{fieldsLength, plural, one {Field} other {Fields}} "{fields}" {fieldsLength, plural, one {has an} other {have}} invalid reference.`, + }), + longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', { + defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`, values: { + layer: layerIndex, fields: fieldsWithBrokenReferences.join('", "'), fieldsLength: fieldsWithBrokenReferences.length, }, - } - ), - }; - } - return { - shortMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureShort', { - defaultMessage: - 'Invalid {fieldsLength, plural, one {reference} other {references}} on Layer {layer}.', - values: { - layer: layerIndex, - fieldsLength: fieldsWithBrokenReferences.length, - }, + }), + }; }), - longMessage: i18n.translate('xpack.lens.indexPattern.dataReferenceFailureLong', { - defaultMessage: `Layer {layer} has {fieldsLength, plural, one {an invalid} other {invalid}} {fieldsLength, plural, one {reference} other {references}} in {fieldsLength, plural, one {field} other {fields}} "{fields}".`, - values: { - layer: layerIndex, - fields: fieldsWithBrokenReferences.join('", "'), - fieldsLength: fieldsWithBrokenReferences.length, - }, - }), - }; - }); + ]; + } }, }; 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 ccdefee62ad5c2..263b4646c9feb5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -18,7 +18,7 @@ import { IndexPatternColumn, OperationType, } from './operations'; -import { hasField, hasInvalidReference } from './utils'; +import { hasField, hasInvalidFields } from './utils'; import { IndexPattern, IndexPatternPrivateState, @@ -90,7 +90,7 @@ export function getDatasourceSuggestionsForField( indexPatternId: string, field: IndexPatternField ): IndexPatternSugestion[] { - if (hasInvalidReference(state)) return []; + if (hasInvalidFields(state)) return []; const layers = Object.keys(state.layers); const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); @@ -331,7 +331,7 @@ function createNewLayerWithMetricAggregation( export function getDatasourceSuggestionsFromCurrentState( state: IndexPatternPrivateState ): Array<DatasourceSuggestion<IndexPatternPrivateState>> { - if (hasInvalidReference(state)) return []; + if (hasInvalidFields(state)) return []; const layers = Object.entries(state.layers || {}); if (layers.length > 1) { // Return suggestions that reduce the data to each layer individually diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 2c6f42668d8638..d0cbcee61db6f6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -6,7 +6,7 @@ import { DragContextState } from '../drag_drop'; import { getFieldByNameFactory } from './pure_helpers'; -import { IndexPattern } from './types'; +import type { IndexPattern } from './types'; export const createMockedIndexPattern = (): IndexPattern => { const fields = [ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts index 72dfe85dfc0e9d..f27fb8d4642f6b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts @@ -6,12 +6,14 @@ const actualOperations = jest.requireActual('../operations'); const actualHelpers = jest.requireActual('../layer_helpers'); +const actualMocks = jest.requireActual('../mocks'); jest.spyOn(actualOperations.operationDefinitionMap.date_histogram, 'paramEditor'); jest.spyOn(actualOperations.operationDefinitionMap.terms, 'onOtherColumnChanged'); jest.spyOn(actualHelpers, 'insertOrReplaceColumn'); jest.spyOn(actualHelpers, 'insertNewColumn'); jest.spyOn(actualHelpers, 'replaceColumn'); +jest.spyOn(actualHelpers, 'getErrorMessages'); export const { getAvailableOperationsByMetadata, @@ -35,4 +37,8 @@ export const { updateLayerIndexPattern, mergeLayer, isColumnTransferable, + getErrorMessages, + isReferenced, } = actualHelpers; + +export const { createMockedReferenceOperation } = actualMocks; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index bd8c4b46833962..fd3ca4319669ed 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -52,6 +52,8 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo (!newField.aggregationRestrictions || newField.aggregationRestrictions.cardinality) ); }, + getDefaultLabel: (column, indexPattern) => + ofName(indexPattern.getFieldByName(column.sourceField)!.displayName), buildColumn({ field, previousColumn }) { return { label: ofName(field.displayName), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index bd4b452a49e1d5..13bddc0c2ec269 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -4,13 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Operation } from '../../../types'; +import type { Operation } from '../../../types'; -/** - * This is the root type of a column. If you are implementing a new - * operation, extend your column type on `BaseIndexPatternColumn` to make - * sure it's matching all the basic requirements. - */ export interface BaseIndexPatternColumn extends Operation { // Private operationType: string; @@ -18,7 +13,8 @@ export interface BaseIndexPatternColumn extends Operation { } // Formatting can optionally be added to any column -export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { +// export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { +export type FormattedIndexPatternColumn = BaseIndexPatternColumn & { params?: { format: { id: string; @@ -27,8 +23,20 @@ export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { }; }; }; -} +}; export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { sourceField: string; } + +export interface ReferenceBasedIndexPatternColumn + extends BaseIndexPatternColumn, + FormattedIndexPatternColumn { + references: string[]; +} + +// Used to store the temporary invalid state +export interface IncompleteColumn { + operationType?: string; + sourceField?: string; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index e33fc681b25794..30f64929fc1afd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -41,6 +41,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn, 'field }; } }, + getDefaultLabel: () => countLabel, buildColumn({ field, previousColumn }) { return { label: countLabel, 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 7d50c28b7465ad..558fab02ad0840 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 @@ -188,7 +188,7 @@ describe('date_histogram', () => { describe('buildColumn', () => { it('should create column object with auto interval for primary time field', () => { const column = dateHistogramOperation.buildColumn({ - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, indexPattern: createMockedIndexPattern(), field: { name: 'timestamp', @@ -204,7 +204,7 @@ describe('date_histogram', () => { it('should create column object with auto interval for non-primary time fields', () => { const column = dateHistogramOperation.buildColumn({ - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, indexPattern: createMockedIndexPattern(), field: { name: 'start_date', @@ -220,7 +220,7 @@ describe('date_histogram', () => { it('should create column object with restrictions', () => { const column = dateHistogramOperation.buildColumn({ - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, indexPattern: createMockedIndexPattern(), field: { name: 'timestamp', 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 659390a42f261f..efac9c151a4353 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 @@ -59,6 +59,8 @@ export const dateHistogramOperation: OperationDefinition< }; } }, + getDefaultLabel: (column, indexPattern) => + indexPattern.getFieldByName(column.sourceField)!.displayName, buildColumn({ field }) { let interval = autoInterval; let timeZone: string | undefined; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 522e951bfba34c..1b0452d18a79ce 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -75,6 +75,7 @@ export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn, 'n input: 'none', isTransferable: () => true, + getDefaultLabel: () => filtersLabel, buildColumn({ previousColumn }) { let params = { filters: [defaultFilter] }; if (previousColumn?.operationType === 'terms') { 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 5c067ebaf21e9f..0e7e125944e719 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ExpressionFunctionAST } from '@kbn/interpreter/common'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { termsOperation, TermsIndexPatternColumn } from './terms'; @@ -24,8 +25,13 @@ import { import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram'; import { countOperation, CountIndexPatternColumn } from './count'; import { StateSetter, OperationMetadata } from '../../../types'; -import { BaseIndexPatternColumn } from './column_types'; -import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types'; +import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; +import { + IndexPatternPrivateState, + IndexPattern, + IndexPatternField, + IndexPatternLayer, +} from '../../types'; import { DateRange } from '../../../../common'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { RangeIndexPatternColumn, rangeOperation } from './ranges'; @@ -50,6 +56,8 @@ export type IndexPatternColumn = export type FieldBasedIndexPatternColumn = Extract<IndexPatternColumn, { sourceField: string }>; +export { IncompleteColumn } from './column_types'; + // List of all operation definitions registered to this data source. // If you want to implement a new operation, add the definition to this array and // the column type to the `IndexPatternColumn` union type below. @@ -104,6 +112,14 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> { * Should be i18n-ified. */ displayName: string; + /** + * The default label is assigned by the editor + */ + getDefaultLabel: ( + column: C, + indexPattern: IndexPattern, + columns: Record<string, IndexPatternColumn> + ) => string; /** * This function is called if another column in the same layer changed or got removed. * Can be used to update references to other columns (e.g. for sorting). @@ -118,11 +134,6 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> { * React component for operation specific settings shown in the popover editor */ paramEditor?: React.ComponentType<ParamEditorProps<C>>; - /** - * Function turning a column into an agg config passed to the `esaggs` function - * together with the agg configs returned from other columns. - */ - toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; /** * Returns true if the `column` can also be used on `newIndexPattern`. * If this function returns false, the column is removed when switching index pattern @@ -138,7 +149,7 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> { } interface BaseBuildColumnArgs { - columns: Partial<Record<string, IndexPatternColumn>>; + layer: IndexPatternLayer; indexPattern: IndexPattern; } @@ -156,7 +167,12 @@ interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn> { * Returns the meta data of the operation if applied. Undefined * if the field is not applicable. */ - getPossibleOperation: () => OperationMetadata | undefined; + getPossibleOperation: () => OperationMetadata; + /** + * Function turning a column into an agg config passed to the `esaggs` function + * together with the agg configs returned from other columns. + */ + toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; } interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> { @@ -167,7 +183,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> { */ getPossibleOperationForField: (field: IndexPatternField) => OperationMetadata | undefined; /** - * Builds the column object for the given parameters. Should include default p + * Builds the column object for the given parameters. */ buildColumn: ( arg: BaseBuildColumnArgs & { @@ -191,11 +207,76 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> { * @param field The field that the user changed to. */ onFieldChange: (oldColumn: C, field: IndexPatternField) => C; + /** + * Function turning a column into an agg config passed to the `esaggs` function + * together with the agg configs returned from other columns. + */ + toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; +} + +export interface RequiredReference { + // Limit the input types, usually used to prevent other references from being used + input: Array<GenericOperationDefinition['input']>; + // Function which is used to determine if the reference is bucketed, or if it's a number + validateMetadata: (metadata: OperationMetadata) => boolean; + // Do not use specificOperations unless you need to limit to only one or two exact + // operation types. The main use case is Cumulative Sum, where we need to only take the + // sum of Count or sum of Sum. + specificOperations?: OperationType[]; +} + +// Full reference uses one or more reference operations which are visible to the user +// Partial reference is similar except that it uses the field selector +interface FullReferenceOperationDefinition<C extends BaseIndexPatternColumn> { + input: 'fullReference'; + /** + * The filters provided here are used to construct the UI, transition correctly + * between operations, and validate the configuration. + */ + requiredReferences: RequiredReference[]; + + /** + * The type of UI that is shown in the editor for this function: + * - full: List of sub-functions and fields + * - field: List of fields, selects first operation per field + */ + selectionStyle: 'full' | 'field'; + + /** + * Builds the column object for the given parameters. Should include default p + */ + buildColumn: ( + arg: BaseBuildColumnArgs & { + referenceIds: string[]; + previousColumn?: IndexPatternColumn; + } + ) => ReferenceBasedIndexPatternColumn & C; + /** + * Returns the meta data of the operation if applied. Undefined + * if the field is not applicable. + */ + getPossibleOperation: () => OperationMetadata; + /** + * A chain of expression functions which will transform the table + */ + toExpression: ( + layer: IndexPatternLayer, + columnId: string, + indexPattern: IndexPattern + ) => ExpressionFunctionAST[]; + /** + * Validate that the operation has the right preconditions in the state. For example: + * + * - Requires a date histogram operation somewhere before it in order + * - Missing references + */ + getErrorMessage?: (layer: IndexPatternLayer, columnId: string) => string[] | undefined; } interface OperationDefinitionMap<C extends BaseIndexPatternColumn> { field: FieldBasedOperationDefinition<C>; none: FieldlessOperationDefinition<C>; + fullReference: FullReferenceOperationDefinition<C>; } /** @@ -220,7 +301,8 @@ export type OperationType = typeof internalOperationDefinitions[number]['type']; */ export type GenericOperationDefinition = | OperationDefinition<IndexPatternColumn, 'field'> - | OperationDefinition<IndexPatternColumn, 'none'>; + | OperationDefinition<IndexPatternColumn, 'none'> + | OperationDefinition<IndexPatternColumn, 'fullReference'>; /** * List of all available operation definitions diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index 37a7ef8ee25631..96df72ba8b7c15 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -52,6 +52,8 @@ function buildMetricOperation<T extends MetricColumn<string>>({ (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) ); }, + getDefaultLabel: (column, indexPattern, columns) => + ofName(indexPattern.getFieldByName(column.sourceField)!.displayName), buildColumn: ({ field, previousColumn }) => ({ label: ofName(field.displayName), dataType: 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx index b1cb2312d5bb8f..d2456e1c8d3751 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx @@ -122,9 +122,11 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field }; } }, + getDefaultLabel: (column, indexPattern) => + indexPattern.getFieldByName(column.sourceField)!.displayName, buildColumn({ field }) { return { - label: field.name, + label: field.displayName, dataType: 'number', // string for Range operationType: 'range', sourceField: field.name, 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 ddc473a5c588d0..7c69a70c093516 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 @@ -17,7 +17,7 @@ import { EuiText, } from '@elastic/eui'; import { IndexPatternColumn } from '../../../indexpattern'; -import { updateColumnParam } from '../../layer_helpers'; +import { updateColumnParam, isReferenced } from '../../layer_helpers'; import { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; @@ -82,13 +82,16 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field (!column.params.otherBucket || !newIndexPattern.hasRestrictions) ); }, - buildColumn({ columns, field, indexPattern }) { - const existingMetricColumn = Object.entries(columns) - .filter(([_columnId, column]) => column && isSortableByColumn(column)) + buildColumn({ layer, field, indexPattern }) { + const existingMetricColumn = Object.entries(layer.columns) + .filter( + ([columnId, column]) => column && !column.isBucketed && !isReferenced(layer, columnId) + ) .map(([id]) => id)[0]; - const previousBucketsLength = Object.values(columns).filter((col) => col && col.isBucketed) - .length; + const previousBucketsLength = Object.values(layer.columns).filter( + (col) => col && col.isBucketed + ).length; return { label: ofName(field.displayName), @@ -131,6 +134,8 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn, 'field }, }; }, + getDefaultLabel: (column, indexPattern) => + ofName(indexPattern.getFieldByName(column.sourceField)!.displayName), onFieldChange: (oldColumn, field) => { const newParams = { ...oldColumn.params }; if ('format' in newParams && field.type !== 'number') { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index bba7bda308b729..e43c7bbd2f72ec 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -270,7 +270,7 @@ describe('terms', () => { name: 'test', displayName: 'test', }, - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, }); expect(termsColumn.dataType).toEqual('boolean'); }); @@ -285,7 +285,7 @@ describe('terms', () => { name: 'test', displayName: 'test', }, - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, }); expect(termsColumn.params.otherBucket).toEqual(true); }); @@ -300,7 +300,7 @@ describe('terms', () => { name: 'test', displayName: 'test', }, - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, }); expect(termsColumn.params.otherBucket).toEqual(false); }); @@ -308,14 +308,18 @@ describe('terms', () => { it('should use existing metric column as order column', () => { const termsColumn = termsOperation.buildColumn({ indexPattern: createMockedIndexPattern(), - columns: { - col1: { - label: 'Count', - dataType: 'number', - isBucketed: false, - sourceField: 'Records', - operationType: 'count', + layer: { + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, }, + columnOrder: [], + indexPatternId: '', }, field: { aggregatable: true, @@ -335,7 +339,7 @@ describe('terms', () => { it('should use the default size when there is an existing bucket', () => { const termsColumn = termsOperation.buildColumn({ indexPattern: createMockedIndexPattern(), - columns: state.layers.first.columns, + layer: state.layers.first, field: { aggregatable: true, searchable: true, @@ -350,7 +354,7 @@ describe('terms', () => { it('should use a size of 5 when there are no other buckets', () => { const termsColumn = termsOperation.buildColumn({ indexPattern: createMockedIndexPattern(), - columns: {}, + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, field: { aggregatable: true, searchable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts index f0e02c7ff0faf5..3ad9a1e5b36749 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts @@ -6,4 +6,11 @@ export * from './operations'; export * from './layer_helpers'; -export { OperationType, IndexPatternColumn, FieldBasedIndexPatternColumn } from './definitions'; +export { + OperationType, + IndexPatternColumn, + FieldBasedIndexPatternColumn, + IncompleteColumn, +} from './definitions'; + +export { createMockedReferenceOperation } from './mocks'; 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 e1a31dc274837c..0d103a766c23a7 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { OperationMetadata } from '../../types'; import { insertNewColumn, replaceColumn, @@ -11,16 +12,20 @@ import { getColumnOrder, deleteColumn, updateLayerIndexPattern, + getErrorMessages, } from './layer_helpers'; import { operationDefinitionMap, OperationType } from '../operations'; import { TermsIndexPatternColumn } from './definitions/terms'; import { DateHistogramIndexPatternColumn } from './definitions/date_histogram'; import { AvgIndexPatternColumn } from './definitions/metrics'; -import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from '../types'; +import type { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from '../types'; import { documentField } from '../document_field'; import { getFieldByNameFactory } from '../pure_helpers'; +import { generateId } from '../../id_generator'; +import { createMockedReferenceOperation } from './mocks'; jest.mock('../operations'); +jest.mock('../../id_generator'); const indexPatternFields = [ { @@ -74,10 +79,22 @@ const indexPattern = { timeFieldName: 'timestamp', hasRestrictions: false, fields: indexPatternFields, - getFieldByName: getFieldByNameFactory(indexPatternFields), + getFieldByName: getFieldByNameFactory([...indexPatternFields, documentField]), }; describe('state_helpers', () => { + beforeEach(() => { + let count = 0; + (generateId as jest.Mock).mockImplementation(() => `id${++count}`); + + // @ts-expect-error we are inserting an invalid type + operationDefinitionMap.testReference = createMockedReferenceOperation(); + }); + + afterEach(() => { + delete operationDefinitionMap.testReference; + }); + describe('insertNewColumn', () => { it('should throw for invalid operations', () => { expect(() => { @@ -315,6 +332,110 @@ describe('state_helpers', () => { }) ).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] })); }); + + describe('inserting a new reference', () => { + it('should throw if the required references are impossible to match', () => { + // @ts-expect-error this function is not valid + operationDefinitionMap.testReference.requiredReferences = [ + { + input: ['none', 'field'], + validateMetadata: () => false, + specificOperations: [], + }, + ]; + const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} }; + expect(() => { + insertNewColumn({ + layer, + indexPattern, + columnId: 'col2', + op: 'testReference' as OperationType, + }); + }).toThrow(); + }); + + it('should leave the references empty if too ambiguous', () => { + const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} }; + const result = insertNewColumn({ + layer, + indexPattern, + columnId: 'col2', + op: 'testReference' as OperationType, + }); + + expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith( + expect.objectContaining({ + referenceIds: ['id1'], + }) + ); + expect(result).toEqual( + expect.objectContaining({ + columns: { + col2: expect.objectContaining({ references: ['id1'] }), + }, + }) + ); + }); + + it('should create an operation if there is exactly one possible match', () => { + // There is only one operation with `none` as the input type + // @ts-expect-error this function is not valid + operationDefinitionMap.testReference.requiredReferences = [ + { + input: ['none'], + validateMetadata: () => true, + }, + ]; + const layer: IndexPatternLayer = { indexPatternId: '1', columnOrder: [], columns: {} }; + const result = insertNewColumn({ + layer, + indexPattern, + columnId: 'col1', + // @ts-expect-error invalid type + op: 'testReference', + }); + expect(result.columnOrder).toEqual(['id1', 'col1']); + expect(result.columns).toEqual( + expect.objectContaining({ + id1: expect.objectContaining({ operationType: 'filters' }), + col1: expect.objectContaining({ references: ['id1'] }), + }) + ); + }); + + it('should create a referenced column if the ID is being used as a reference', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + dataType: 'number', + isBucketed: false, + + // @ts-expect-error only in test + operationType: 'testReference', + references: ['ref1'], + }, + }, + }; + expect( + insertNewColumn({ + layer, + indexPattern, + columnId: 'ref1', + op: 'count', + field: documentField, + }) + ).toEqual( + expect.objectContaining({ + columns: { + col1: expect.objectContaining({ references: ['ref1'] }), + ref1: expect.objectContaining({}), + }, + }) + ); + }); + }); }); describe('replaceColumn', () => { @@ -655,10 +776,301 @@ describe('state_helpers', () => { }), }); }); + + it('should not wrap the previous operation when switching to reference', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Count', + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + sourceField: 'Records', + operationType: 'count' as const, + }, + }, + }; + const result = replaceColumn({ + layer, + indexPattern, + columnId: 'col1', + op: 'testReference' as OperationType, + }); + + expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith( + expect.objectContaining({ + referenceIds: ['id1'], + }) + ); + expect(result.columns).toEqual( + expect.objectContaining({ + col1: expect.objectContaining({ operationType: 'testReference' }), + }) + ); + }); + + it('should delete the previous references and reset to default values when going from reference to no-input', () => { + // @ts-expect-error this function is not valid + operationDefinitionMap.testReference.requiredReferences = [ + { + input: ['none'], + validateMetadata: () => true, + }, + ]; + const expectedCol = { + dataType: 'string' as const, + isBucketed: true, + + operationType: 'filters' as const, + params: { + // These filters are reset + filters: [{ input: { query: 'field: true', language: 'kuery' }, label: 'Custom label' }], + }, + }; + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + ...expectedCol, + label: 'Custom label', + customLabel: true, + }, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }; + expect( + replaceColumn({ + layer, + indexPattern, + columnId: 'col2', + op: 'filters', + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col2'], + columns: { + col2: { + ...expectedCol, + label: 'Filters', + scale: 'ordinal', // added in buildColumn + params: { + filters: [{ input: { query: '', language: 'kuery' }, label: '' }], + }, + }, + }, + }) + ); + }); + + it('should delete the inner references when switching away from reference to field-based operation', () => { + const expectedCol = { + label: 'Count of records', + dataType: 'number' as const, + isBucketed: false, + + operationType: 'count' as const, + sourceField: 'Records', + }; + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: expectedCol, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }; + expect( + replaceColumn({ + layer, + indexPattern, + columnId: 'col2', + op: 'count', + field: documentField, + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col2'], + columns: { + col2: expect.objectContaining(expectedCol), + }, + }) + ); + }); + + it('should reset when switching from one reference to another', () => { + operationDefinitionMap.secondTest = { + input: 'fullReference', + displayName: 'Reference test 2', + // @ts-expect-error this type is not statically available + type: 'secondTest', + requiredReferences: [ + { + // Any numeric metric that isn't also a reference + input: ['none', 'field'], + validateMetadata: (meta: OperationMetadata) => + meta.dataType === 'number' && !meta.isBucketed, + }, + ], + // @ts-expect-error don't want to define valid arguments + buildColumn: jest.fn((args) => { + return { + label: 'Test reference', + isBucketed: false, + dataType: 'number', + + operationType: 'secondTest', + references: args.referenceIds, + }; + }), + isTransferable: jest.fn(), + toExpression: jest.fn().mockReturnValue([]), + getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }), + }; + + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count', + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + + operationType: 'count' as const, + sourceField: 'Records', + }, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }; + expect( + replaceColumn({ + layer, + indexPattern, + columnId: 'col2', + // @ts-expect-error not statically available + op: 'secondTest', + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col2'], + columns: { + col2: expect.objectContaining({ references: ['id1'] }), + }, + incompleteColumns: {}, + }) + ); + + delete operationDefinitionMap.secondTest; + }); + + it('should allow making a replacement on an operation that is being referenced, even if it ends up invalid', () => { + // @ts-expect-error this function is not valid + operationDefinitionMap.testReference.requiredReferences = [ + { + input: ['field'], + validateMetadata: (meta: OperationMetadata) => meta.dataType === 'number', + specificOperations: ['sum'], + }, + ]; + + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Asdf', + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + + operationType: 'sum' as const, + sourceField: 'bytes', + }, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }; + expect( + replaceColumn({ + layer, + indexPattern, + columnId: 'col1', + op: 'count', + field: documentField, + }) + ).toEqual( + expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + sourceField: 'Records', + operationType: 'count', + }), + col2: expect.objectContaining({ references: ['col1'] }), + }, + }) + ); + }); }); describe('deleteColumn', () => { - it('should remove column', () => { + it('should clear incomplete columns when column is already empty', () => { + expect( + deleteColumn({ + layer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + incompleteColumns: { + col1: { sourceField: 'test' }, + }, + }, + columnId: 'col1', + }) + ).toEqual({ + indexPatternId: '1', + columnOrder: [], + columns: {}, + incompleteColumns: {}, + }); + }); + + it('should remove column and any incomplete state', () => { const termsColumn: TermsIndexPatternColumn = { label: 'Top values of source', dataType: 'string', @@ -682,25 +1094,33 @@ describe('state_helpers', () => { columns: { col1: termsColumn, col2: { - label: 'Count', + label: 'Count of records', dataType: 'number', isBucketed: false, sourceField: 'Records', operationType: 'count', }, }, + incompleteColumns: { + col2: { sourceField: 'other' }, + }, }, columnId: 'col2', - }).columns + }) ).toEqual({ - col1: { - ...termsColumn, - params: { - ...termsColumn.params, - orderBy: { type: 'alphabetical' }, - orderDirection: 'asc', + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + ...termsColumn, + params: { + ...termsColumn.params, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, }, }, + incompleteColumns: {}, }); }); @@ -742,6 +1162,73 @@ describe('state_helpers', () => { col1: termsColumn, }); }); + + it('should delete the column and all of its references', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'Records', + }, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }; + expect(deleteColumn({ layer, columnId: 'col2' })).toEqual( + expect.objectContaining({ columnOrder: [], columns: {} }) + ); + }); + + it('should recursively delete references', () => { + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + operationType: 'count', + sourceField: 'Records', + }, + col2: { + label: 'Test reference', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + col3: { + label: 'Test reference 2', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col2'], + }, + }, + }; + expect(deleteColumn({ layer, columnId: 'col3' })).toEqual( + expect.objectContaining({ columnOrder: [], columns: {} }) + ); + }); }); describe('updateColumnParam', () => { @@ -913,6 +1400,60 @@ describe('state_helpers', () => { }) ).toEqual(['col1', 'col3', 'col2']); }); + + it('should correctly sort references to other references', () => { + expect( + getColumnOrder({ + columnOrder: [], + indexPatternId: '', + columns: { + bucket: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }, + metric: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + ref2: { + label: 'Ref2', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error only for testing + operationType: 'testReference', + references: ['ref1'], + }, + ref1: { + label: 'Ref', + dataType: 'number', + isBucketed: false, + + // @ts-expect-error only for testing + operationType: 'testReference', + references: ['bucket'], + }, + }, + }) + ).toEqual(['bucket', 'metric', 'ref1', 'ref2']); + }); }); describe('updateLayerIndexPattern', () => { @@ -1141,4 +1682,67 @@ describe('state_helpers', () => { }); }); }); + + describe('getErrorMessages', () => { + it('should collect errors from the operation definitions', () => { + const mock = jest.fn().mockReturnValue(['error 1']); + // @ts-expect-error not statically analyzed + operationDefinitionMap.testReference.getErrorMessage = mock; + const errors = getErrorMessages({ + indexPatternId: '1', + columnOrder: [], + columns: { + col1: + // @ts-expect-error not statically analyzed + { operationType: 'testReference', references: [] }, + }, + }); + expect(mock).toHaveBeenCalled(); + expect(errors).toHaveLength(1); + }); + + it('should identify missing references', () => { + const errors = getErrorMessages({ + indexPatternId: '1', + columnOrder: [], + columns: { + col1: + // @ts-expect-error not statically analyzed yet + { operationType: 'testReference', references: ['ref1', 'ref2'] }, + }, + }); + expect(errors).toHaveLength(2); + }); + + it('should identify references that are no longer valid', () => { + // There is only one operation with `none` as the input type + // @ts-expect-error this function is not valid + operationDefinitionMap.testReference.requiredReferences = [ + { + input: ['none'], + validateMetadata: () => true, + }, + ]; + + const errors = getErrorMessages({ + indexPatternId: '1', + columnOrder: [], + columns: { + // @ts-expect-error incomplete operation + ref1: { + dataType: 'string', + isBucketed: true, + operationType: 'terms', + }, + col1: { + label: '', + references: ['ref1'], + // @ts-expect-error tests only + operationType: 'testReference', + }, + }, + }); + expect(errors).toHaveLength(1); + }); + }); }); 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 f071df15421471..1495a876a2c8eb 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 @@ -5,13 +5,15 @@ */ import _, { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { operationDefinitionMap, operationDefinitions, OperationType, IndexPatternColumn, + RequiredReference, } from './definitions'; -import { +import type { IndexPattern, IndexPatternField, IndexPatternLayer, @@ -19,6 +21,7 @@ import { } from '../types'; import { getSortScoreByPriority } from './operations'; import { mergeLayer } from '../state_helpers'; +import { generateId } from '../../id_generator'; interface ColumnChange { op: OperationType; @@ -35,6 +38,8 @@ export function insertOrReplaceColumn(args: ColumnChange): IndexPatternLayer { return insertNewColumn(args); } +// Insert a column into an empty ID. The field parameter is required when constructing +// a field-based operation, but will cause the function to fail for any other type of operation. export function insertNewColumn({ op, layer, @@ -48,24 +53,102 @@ export function insertNewColumn({ throw new Error('No suitable operation found for given parameters'); } - const baseOptions = { - columns: layer.columns, - indexPattern, - previousColumn: layer.columns[columnId], - }; + if (layer.columns[columnId]) { + throw new Error(`Can't insert a column with an ID that is already in use`); + } - // TODO: Reference based operations require more setup to create the references + const baseOptions = { indexPattern, previousColumn: layer.columns[columnId] }; if (operationDefinition.input === 'none') { + if (field) { + throw new Error(`Can't create operation ${op} with the provided field ${field.name}`); + } const possibleOperation = operationDefinition.getPossibleOperation(); - if (!possibleOperation) { - throw new Error('Tried to create an invalid operation'); + const isBucketed = Boolean(possibleOperation.isBucketed); + if (isBucketed) { + return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId); + } else { + return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, layer }), columnId); } + } + + if (operationDefinition.input === 'fullReference') { + if (field) { + throw new Error(`Reference-based operations can't take a field as input when creating`); + } + let tempLayer = { ...layer }; + const referenceIds = operationDefinition.requiredReferences.map((validation) => { + // TODO: This logic is too simple because it's not using fields. Once we have + // access to the operationSupportMatrix, we should validate the metadata against + // the possible fields + const validOperations = Object.values(operationDefinitionMap).filter(({ type }) => + isOperationAllowedAsReference({ validation, operationType: type }) + ); + + if (!validOperations.length) { + throw new Error( + `Can't create reference, ${op} has a validation function which doesn't allow any operations` + ); + } + + const newId = generateId(); + if (validOperations.length === 1) { + const def = validOperations[0]; + + const validFields = + def.input === 'field' ? indexPattern.fields.filter(def.getPossibleOperationForField) : []; + + if (def.input === 'none') { + tempLayer = insertNewColumn({ + layer: tempLayer, + columnId: newId, + op: def.type, + indexPattern, + }); + } else if (validFields.length === 1) { + // Recursively update the layer for each new reference + tempLayer = insertNewColumn({ + layer: tempLayer, + columnId: newId, + op: def.type, + indexPattern, + field: validFields[0], + }); + } else { + tempLayer = { + ...tempLayer, + incompleteColumns: { + ...tempLayer.incompleteColumns, + [newId]: { operationType: def.type }, + }, + }; + } + } + return newId; + }); + + const possibleOperation = operationDefinition.getPossibleOperation(); const isBucketed = Boolean(possibleOperation.isBucketed); if (isBucketed) { - return addBucket(layer, operationDefinition.buildColumn(baseOptions), columnId); + return addBucket( + tempLayer, + operationDefinition.buildColumn({ + ...baseOptions, + layer: tempLayer, + referenceIds, + }), + columnId + ); } else { - return addMetric(layer, operationDefinition.buildColumn(baseOptions), columnId); + return addMetric( + tempLayer, + operationDefinition.buildColumn({ + ...baseOptions, + layer: tempLayer, + referenceIds, + }), + columnId + ); } } @@ -81,9 +164,17 @@ export function insertNewColumn({ } const isBucketed = Boolean(possibleOperation.isBucketed); if (isBucketed) { - return addBucket(layer, operationDefinition.buildColumn({ ...baseOptions, field }), columnId); + return addBucket( + layer, + operationDefinition.buildColumn({ ...baseOptions, layer, field }), + columnId + ); } else { - return addMetric(layer, operationDefinition.buildColumn({ ...baseOptions, field }), columnId); + return addMetric( + layer, + operationDefinition.buildColumn({ ...baseOptions, layer, field }), + columnId + ); } } @@ -99,8 +190,9 @@ export function replaceColumn({ throw new Error(`Can't replace column because there is no prior column`); } - const isNewOperation = Boolean(op) && op !== previousColumn.operationType; - const operationDefinition = operationDefinitionMap[op || previousColumn.operationType]; + const isNewOperation = op !== previousColumn.operationType; + const operationDefinition = operationDefinitionMap[op]; + const previousDefinition = operationDefinitionMap[previousColumn.operationType]; if (!operationDefinition) { throw new Error('No suitable operation found for given parameters'); @@ -113,22 +205,49 @@ export function replaceColumn({ }; if (isNewOperation) { - // TODO: Reference based operations require more setup to create the references + let tempLayer = { ...layer }; - if (operationDefinition.input === 'none') { - const newColumn = operationDefinition.buildColumn(baseOptions); + if (previousDefinition.input === 'fullReference') { + // @ts-expect-error references are not statically analyzed + previousColumn.references.forEach((id: string) => { + tempLayer = deleteColumn({ layer: tempLayer, columnId: id }); + }); + } + if (operationDefinition.input === 'fullReference') { + const referenceIds = operationDefinition.requiredReferences.map(() => generateId()); + + const incompleteColumns = { ...(tempLayer.incompleteColumns || {}) }; + delete incompleteColumns[columnId]; + const newColumns = { + ...tempLayer.columns, + [columnId]: operationDefinition.buildColumn({ + ...baseOptions, + layer: tempLayer, + referenceIds, + previousColumn, + }), + }; + return { + ...tempLayer, + columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }), + columns: newColumns, + incompleteColumns, + }; + } + + if (operationDefinition.input === 'none') { + const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer }); if (previousColumn.customLabel) { newColumn.customLabel = true; newColumn.label = previousColumn.label; } + const newColumns = { ...tempLayer.columns, [columnId]: newColumn }; return { - ...layer, - columns: adjustColumnReferencesForChangedColumn( - { ...layer.columns, [columnId]: newColumn }, - columnId - ), + ...tempLayer, + columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }), + columns: adjustColumnReferencesForChangedColumn(newColumns, columnId), }; } @@ -136,17 +255,17 @@ export function replaceColumn({ throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`); } - const newColumn = operationDefinition.buildColumn({ ...baseOptions, field }); + const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, field }); if (previousColumn.customLabel) { newColumn.customLabel = true; newColumn.label = previousColumn.label; } - const newColumns = { ...layer.columns, [columnId]: newColumn }; + const newColumns = { ...tempLayer.columns, [columnId]: newColumn }; return { - ...layer, - columnOrder: getColumnOrder({ ...layer, columns: newColumns }), + ...tempLayer, + columnOrder: getColumnOrder({ ...tempLayer, columns: newColumns }), columns: adjustColumnReferencesForChangedColumn(newColumns, columnId), }; } else if ( @@ -294,23 +413,61 @@ export function deleteColumn({ layer: IndexPatternLayer; columnId: string; }): IndexPatternLayer { + const column = layer.columns[columnId]; + if (!column) { + const newIncomplete = { ...(layer.incompleteColumns || {}) }; + delete newIncomplete[columnId]; + return { + ...layer, + columnOrder: layer.columnOrder.filter((id) => id !== columnId), + incompleteColumns: newIncomplete, + }; + } + + // @ts-expect-error this fails statically because there are no references added + const extraDeletions: string[] = 'references' in column ? column.references : []; + const hypotheticalColumns = { ...layer.columns }; delete hypotheticalColumns[columnId]; - const newLayer = { + let newLayer = { ...layer, columns: adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId), }; - return { ...newLayer, columnOrder: getColumnOrder(newLayer) }; + + extraDeletions.forEach((id) => { + newLayer = deleteColumn({ layer: newLayer, columnId: id }); + }); + + const newIncomplete = { ...(newLayer.incompleteColumns || {}) }; + delete newIncomplete[columnId]; + + return { ...newLayer, columnOrder: getColumnOrder(newLayer), incompleteColumns: newIncomplete }; } export function getColumnOrder(layer: IndexPatternLayer): string[] { - const [aggregations, metrics] = _.partition( + const [direct, referenceBased] = _.partition( Object.entries(layer.columns), - ([id, col]) => col.isBucketed + ([id, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference' ); + // If a reference has another reference as input, put it last in sort order + referenceBased.sort(([idA, a], [idB, b]) => { + // @ts-expect-error not statically analyzed + if ('references' in a && a.references.includes(idB)) { + return 1; + } + // @ts-expect-error not statically analyzed + if ('references' in b && b.references.includes(idA)) { + return -1; + } + return 0; + }); + const [aggregations, metrics] = _.partition(direct, ([, col]) => col.isBucketed); - return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id)); + return aggregations + .map(([id]) => id) + .concat(metrics.map(([id]) => id)) + .concat(referenceBased.map(([id]) => id)); } /** @@ -342,3 +499,116 @@ export function updateLayerIndexPattern( columnOrder: newColumnOrder, }; } + +/** + * Collects all errors from the columns in the layer, for display in the workspace. This includes: + * + * - All columns have complete references + * - All column references are valid + * - All prerequisites are met + */ +export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined { + const errors: string[] = []; + + Object.entries(layer.columns).forEach(([columnId, column]) => { + const def = operationDefinitionMap[column.operationType]; + if (def.input === 'fullReference' && def.getErrorMessage) { + errors.push(...(def.getErrorMessage(layer, columnId) ?? [])); + } + + if ('references' in column) { + // @ts-expect-error references are not statically analyzed yet + column.references.forEach((referenceId, index) => { + if (!layer.columns[referenceId]) { + errors.push( + i18n.translate('xpack.lens.indexPattern.missingReferenceError', { + defaultMessage: 'Dimension {dimensionLabel} is incomplete', + values: { + // @ts-expect-error references are not statically analyzed yet + dimensionLabel: column.label, + }, + }) + ); + } else { + const referenceColumn = layer.columns[referenceId]!; + const requirements = + // @ts-expect-error not statically analyzed + operationDefinitionMap[column.operationType].requiredReferences[index]; + const isValid = isColumnValidAsReference({ + validation: requirements, + column: referenceColumn, + }); + + if (!isValid) { + errors.push( + i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', { + defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration', + values: { + // @ts-expect-error references are not statically analyzed yet + dimensionLabel: column.label, + }, + }) + ); + } + } + }); + } + }); + + return errors.length ? errors : undefined; +} + +export function isReferenced(layer: IndexPatternLayer, columnId: string): boolean { + const allReferences = Object.values(layer.columns).flatMap((col) => + 'references' in col + ? // @ts-expect-error not statically analyzed + col.references + : [] + ); + return allReferences.includes(columnId); +} + +function isColumnValidAsReference({ + column, + validation, +}: { + column: IndexPatternColumn; + validation: RequiredReference; +}): boolean { + if (!column) return false; + const operationType = column.operationType; + const operationDefinition = operationDefinitionMap[operationType]; + return ( + validation.input.includes(operationDefinition.input) && + (!validation.specificOperations || validation.specificOperations.includes(operationType)) && + validation.validateMetadata(column) + ); +} + +function isOperationAllowedAsReference({ + operationType, + validation, + field, +}: { + operationType: OperationType; + validation: RequiredReference; + field?: IndexPatternField; +}): boolean { + const operationDefinition = operationDefinitionMap[operationType]; + + let hasValidMetadata = true; + if (field && operationDefinition.input === 'field') { + const metadata = operationDefinition.getPossibleOperationForField(field); + hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!); + } else if (operationDefinition.input !== 'field') { + const metadata = operationDefinition.getPossibleOperation(); + hasValidMetadata = Boolean(metadata) && validation.validateMetadata(metadata!); + } else { + // TODO: How can we validate the metadata without a specific field? + } + return ( + validation.input.includes(operationDefinition.input) && + (!validation.specificOperations || validation.specificOperations.includes(operationType)) && + hasValidMetadata + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts new file mode 100644 index 00000000000000..c3f7dac03ada30 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { OperationMetadata } from '../../types'; +import type { OperationType } from './definitions'; + +export const createMockedReferenceOperation = () => { + return { + input: 'fullReference', + displayName: 'Reference test', + type: 'testReference' as OperationType, + selectionStyle: 'full', + requiredReferences: [ + { + // Any numeric metric that isn't also a reference + input: ['none', 'field'], + validateMetadata: (meta: OperationMetadata) => + meta.dataType === 'number' && !meta.isBucketed, + }, + ], + buildColumn: jest.fn((args) => { + return { + label: 'Test reference', + isBucketed: false, + dataType: 'number', + + operationType: 'testReference', + references: args.referenceIds, + }; + }), + isTransferable: jest.fn(), + toExpression: jest.fn().mockReturnValue([]), + getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }), + getDefaultLabel: jest.fn().mockReturnValue('Default label'), + }; +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index 8d489df3660887..58685fa494a046 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -87,6 +87,10 @@ type OperationFieldTuple = | { type: 'none'; operationType: OperationType; + } + | { + type: 'fullReference'; + operationType: OperationType; }; /** @@ -162,6 +166,11 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { }, operationDefinition.getPossibleOperation() ); + } else if (operationDefinition.input === 'fullReference') { + addToMap( + { type: 'fullReference', operationType: operationDefinition.type }, + operationDefinition.getPossibleOperation() + ); } }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index ea7aa62054e5c4..5b66d4aae77abd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -7,32 +7,29 @@ import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; import { IndexPatternColumn } from './indexpattern'; import { operationDefinitionMap } from './operations'; -import { IndexPattern, IndexPatternPrivateState } from './types'; +import { IndexPattern, IndexPatternPrivateState, IndexPatternLayer } from './types'; import { OriginalColumn } from './rename_columns'; import { dateHistogramOperation } from './operations/definitions'; -function getExpressionForLayer( - indexPattern: IndexPattern, - columns: Record<string, IndexPatternColumn>, - columnOrder: string[] -): Ast | null { +function getExpressionForLayer(layer: IndexPatternLayer, indexPattern: IndexPattern): Ast | null { + const { columns, columnOrder } = layer; + if (columnOrder.length === 0) { return null; } - function getEsAggsConfig<C extends IndexPatternColumn>(column: C, columnId: string) { - return operationDefinitionMap[column.operationType].toEsAggsConfig( - column, - columnId, - indexPattern - ); - } - const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); if (columnEntries.length) { - const aggs = columnEntries.map(([colId, col]) => { - return getEsAggsConfig(col, colId); + const aggs: unknown[] = []; + const expressions: ExpressionFunctionAST[] = []; + columnEntries.forEach(([colId, col]) => { + const def = operationDefinitionMap[col.operationType]; + if (def.input === 'fullReference') { + expressions.push(...def.toExpression(layer, colId, indexPattern)); + } else { + aggs.push(def.toEsAggsConfig(col, colId, indexPattern)); + } }); const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { @@ -119,6 +116,7 @@ function getExpressionForLayer( }, }, ...formatterOverrides, + ...expressions, ], }; } @@ -129,9 +127,8 @@ function getExpressionForLayer( export function toExpression(state: IndexPatternPrivateState, layerId: string) { if (state.layers[layerId]) { return getExpressionForLayer( - state.indexPatterns[state.layers[layerId].indexPatternId], - state.layers[layerId].columns, - state.layers[layerId].columnOrder + state.layers[layerId], + state.indexPatterns[state.layers[layerId].indexPatternId] ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 1e6fc5a5806b55..e4958da471417a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -5,7 +5,7 @@ */ import { IFieldType } from 'src/plugins/data/common'; -import { IndexPatternColumn } from './operations'; +import { IndexPatternColumn, IncompleteColumn } from './operations'; import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/public'; export interface IndexPattern { @@ -35,6 +35,8 @@ export interface IndexPatternLayer { columns: Record<string, IndexPatternColumn>; // Each layer is tied to the index pattern that created it indexPatternId: string; + // Partial columns represent the temporary invalid states + incompleteColumns?: Record<string, IncompleteColumn>; } export interface IndexPatternPersistedState { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index d0ea81d1351563..01b834610eb1ae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -42,11 +42,11 @@ export function isDraggedField(fieldCandidate: unknown): fieldCandidate is Dragg ); } -export function hasInvalidReference(state: IndexPatternPrivateState) { - return getInvalidReferences(state).length > 0; +export function hasInvalidFields(state: IndexPatternPrivateState) { + return getInvalidLayers(state).length > 0; } -export function getInvalidReferences(state: IndexPatternPrivateState) { +export function getInvalidLayers(state: IndexPatternPrivateState) { return Object.values(state.layers).filter((layer) => { return layer.columnOrder.some((columnId) => { const column = layer.columns[columnId]; @@ -62,7 +62,7 @@ export function getInvalidReferences(state: IndexPatternPrivateState) { }); } -export function getInvalidFieldReferencesForLayer( +export function getInvalidFieldsForLayer( layers: IndexPatternLayer[], indexPatternMap: Record<string, IndexPattern> ) { 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 a4c1e1bd4ba167..a4b5d741c80f1f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -18,7 +18,7 @@ import { Fit, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; -import { xyChart, XYChart } from './expression'; +import { calculateMinInterval, xyChart, XYChart, XYChartProps } from './expression'; import { LensMultiTable } from '../types'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; @@ -287,6 +287,10 @@ function sampleArgs() { { a: 1, b: 5, c: 'J', d: 'Bar' }, ]), }, + dateRange: { + fromDate: new Date('2019-01-02T05:00:00.000Z'), + toDate: new Date('2019-01-03T05:00:00.000Z'), + }, }; const args: XYArgs = createArgsWithLayers(); @@ -425,7 +429,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -449,7 +453,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -502,7 +506,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={undefined} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -516,7 +520,7 @@ describe('xy_expression', () => { `); }); - test('it generates correct xDomain for a layer with single value and a layer with no data (1-0) ', () => { + test('it uses passed in minInterval', () => { const data: LensMultiTable = { type: 'lens_multitable', tables: { @@ -539,7 +543,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -550,132 +554,10 @@ describe('xy_expression', () => { Object { "max": 1546491600000, "min": 1546405200000, - "minInterval": 1728000, + "minInterval": 50, } `); }); - - test('it generates correct xDomain for two layers with single value(1-1)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), - second: createSampleDatatableWithRows([{ a: 10, b: 5, c: 'J', d: 'Bar' }]), - }, - }; - const component = shallow( - <XYChart - data={{ - ...data, - dateRange: { - fromDate: new Date('2019-01-02T05:00:00.000Z'), - toDate: new Date('2019-01-03T05:00:00.000Z'), - }, - }} - args={multiLayerArgs} - formatFactory={getFormatSpy} - timeZone="UTC" - chartsThemeService={chartsThemeService} - paletteService={paletteService} - histogramBarTarget={50} - onClickValue={onClickValue} - onSelectRange={onSelectRange} - /> - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); - test('it generates correct xDomain for a layer with single value and layer with multiple value data (1-n)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([{ a: 1, b: 2, c: 'I', d: 'Foo' }]), - second: createSampleDatatableWithRows([ - { a: 10, b: 5, c: 'J', d: 'Bar' }, - { a: 8, b: 5, c: 'K', d: 'Buzz' }, - ]), - }, - }; - const component = shallow( - <XYChart - data={{ - ...data, - dateRange: { - fromDate: new Date('2019-01-02T05:00:00.000Z'), - toDate: new Date('2019-01-03T05:00:00.000Z'), - }, - }} - args={multiLayerArgs} - formatFactory={getFormatSpy} - timeZone="UTC" - chartsThemeService={chartsThemeService} - paletteService={paletteService} - histogramBarTarget={50} - onClickValue={onClickValue} - onSelectRange={onSelectRange} - /> - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); - - test('it generates correct xDomain for 2 layers with multiple value data (n-n)', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: createSampleDatatableWithRows([ - { a: 1, b: 2, c: 'I', d: 'Foo' }, - { a: 8, b: 5, c: 'K', d: 'Buzz' }, - { a: 9, b: 7, c: 'L', d: 'Bar' }, - { a: 10, b: 2, c: 'G', d: 'Bear' }, - ]), - second: createSampleDatatableWithRows([ - { a: 10, b: 5, c: 'J', d: 'Bar' }, - { a: 8, b: 4, c: 'K', d: 'Fi' }, - { a: 1, b: 8, c: 'O', d: 'Pi' }, - ]), - }, - }; - const component = shallow( - <XYChart - data={{ - ...data, - dateRange: { - fromDate: new Date('2019-01-02T05:00:00.000Z'), - toDate: new Date('2019-01-03T05:00:00.000Z'), - }, - }} - args={multiLayerArgs} - formatFactory={getFormatSpy} - timeZone="UTC" - chartsThemeService={chartsThemeService} - paletteService={paletteService} - histogramBarTarget={50} - onClickValue={onClickValue} - onSelectRange={onSelectRange} - /> - ); - - expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` - Object { - "max": 1546491600000, - "min": 1546405200000, - "minInterval": undefined, - } - `); - }); }); test('it does not use date range if the x is not a time scale', () => { @@ -698,7 +580,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -716,7 +598,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -737,7 +619,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -758,7 +640,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -784,7 +666,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -808,7 +690,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -893,7 +775,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -945,7 +827,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -983,7 +865,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1004,7 +886,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1028,7 +910,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1061,7 +943,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1081,7 +963,7 @@ describe('xy_expression', () => { timeZone="CEST" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1107,7 +989,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1127,7 +1009,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1150,7 +1032,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1178,7 +1060,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1200,7 +1082,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1601,7 +1483,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1621,7 +1503,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1641,7 +1523,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1660,7 +1542,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} timeZone="UTC" onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1683,7 +1565,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1718,7 +1600,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1751,7 +1633,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1784,7 +1666,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1817,7 +1699,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1917,7 +1799,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1991,7 +1873,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2063,7 +1945,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2087,7 +1969,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2110,7 +1992,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2133,7 +2015,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2168,7 +2050,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2195,7 +2077,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2217,7 +2099,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2244,7 +2126,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2277,7 +2159,7 @@ describe('xy_expression', () => { timeZone="UTC" chartsThemeService={chartsThemeService} paletteService={paletteService} - histogramBarTarget={50} + minInterval={50} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -2288,4 +2170,47 @@ describe('xy_expression', () => { }); }); }); + + describe('calculateMinInterval', () => { + let xyProps: XYChartProps; + + beforeEach(() => { + xyProps = sampleArgs(); + xyProps.args.layers[0].xScaleType = 'time'; + }); + it('should use first valid layer and determine interval', async () => { + const result = await calculateMinInterval( + xyProps, + jest.fn().mockResolvedValue({ interval: '5m' }) + ); + expect(result).toEqual(5 * 60 * 1000); + }); + + it('should return undefined if data table is empty', async () => { + xyProps.data.tables.first.rows = []; + const result = await calculateMinInterval( + xyProps, + jest.fn().mockResolvedValue({ interval: '5m' }) + ); + expect(result).toEqual(undefined); + }); + + it('should return undefined if interval can not be checked', async () => { + const result = await calculateMinInterval(xyProps, jest.fn().mockResolvedValue(undefined)); + expect(result).toEqual(undefined); + }); + + it('should return undefined if date column is not found', async () => { + xyProps.data.tables.first.columns.splice(2, 1); + const result = await calculateMinInterval(xyProps, jest.fn().mockResolvedValue(undefined)); + expect(result).toEqual(undefined); + }); + + it('should return undefined if x axis is not a date', async () => { + xyProps.args.layers[0].xScaleType = 'ordinal'; + xyProps.data.tables.first.columns.splice(2, 1); + const result = await calculateMinInterval(xyProps, jest.fn().mockResolvedValue(undefined)); + expect(result).toEqual(undefined); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 8713e3989a1b67..54ae3bb759d2ca 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -8,7 +8,6 @@ import './expression.scss'; import React, { useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; -import moment from 'moment'; import { Chart, Settings, @@ -39,10 +38,14 @@ import { LensFilterEvent, LensBrushEvent, } from '../types'; -import { XYArgs, SeriesType, visualizationTypes } from './types'; +import { XYArgs, SeriesType, visualizationTypes, LayerArgs } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; -import { ExpressionValueSearchContext, search } from '../../../../../src/plugins/data/public'; +import { + DataPublicPluginStart, + ExpressionValueSearchContext, + search, +} from '../../../../../src/plugins/data/public'; import { ChartsPluginSetup, PaletteRegistry, @@ -75,7 +78,7 @@ type XYChartRenderProps = XYChartProps & { paletteService: PaletteRegistry; formatFactory: FormatFactory; timeZone: string; - histogramBarTarget: number; + minInterval: number | undefined; onClickValue: (data: LensFilterEvent['data']) => void; onSelectRange: (data: LensBrushEvent['data']) => void; }; @@ -174,11 +177,31 @@ export const xyChart: ExpressionFunctionDefinition< }, }; +export async function calculateMinInterval( + { args: { layers }, data }: XYChartProps, + getIntervalByColumn: DataPublicPluginStart['search']['aggs']['getDateMetaByDatatableColumn'] +) { + const filteredLayers = getFilteredLayers(layers, data); + if (filteredLayers.length === 0) return; + const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); + + if (!isTimeViz) return; + const dateColumn = data.tables[filteredLayers[0].layerId].columns.find( + (column) => column.id === filteredLayers[0].xAccessor + ); + if (!dateColumn) return; + const dateMetaData = await getIntervalByColumn(dateColumn); + if (!dateMetaData) return; + const intervalDuration = search.aggs.parseInterval(dateMetaData.interval); + if (!intervalDuration) return; + return intervalDuration.as('milliseconds'); +} + export const getXyChartRenderer = (dependencies: { formatFactory: Promise<FormatFactory>; chartsThemeService: ChartsPluginSetup['theme']; paletteService: PaletteRegistry; - histogramBarTarget: number; + getIntervalByColumn: DataPublicPluginStart['search']['aggs']['getDateMetaByDatatableColumn']; timeZone: string; }): ExpressionRenderDefinition<XYChartProps> => ({ name: 'lens_xy_chart_renderer', @@ -209,7 +232,7 @@ export const getXyChartRenderer = (dependencies: { chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} timeZone={dependencies.timeZone} - histogramBarTarget={dependencies.histogramBarTarget} + minInterval={await calculateMinInterval(config, dependencies.getIntervalByColumn)} onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -277,7 +300,7 @@ export function XYChart({ timeZone, chartsThemeService, paletteService, - histogramBarTarget, + minInterval, onClickValue, onSelectRange, }: XYChartRenderProps) { @@ -285,19 +308,7 @@ export function XYChart({ const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); - const filteredLayers = layers.filter(({ layerId, xAccessor, accessors, splitAccessor }) => { - return !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) - ); - }); + const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; @@ -348,37 +359,6 @@ export function XYChart({ filteredBarLayers.some((layer) => layer.accessors.length > 1) || filteredBarLayers.some((layer) => layer.splitAccessor); - function calculateMinInterval() { - // check all the tables to see if all of the rows have the same timestamp - // that would mean that chart will draw a single bar - const isSingleTimestampInXDomain = () => { - const firstRowValue = - data.tables[filteredLayers[0].layerId].rows[0][filteredLayers[0].xAccessor!]; - for (const layer of filteredLayers) { - if ( - layer.xAccessor && - data.tables[layer.layerId].rows.some((row) => row[layer.xAccessor!] !== firstRowValue) - ) { - return false; - } - } - return true; - }; - - // add minInterval only for single point in domain - if (data.dateRange && isSingleTimestampInXDomain()) { - const params = xAxisColumn?.meta?.sourceParams?.params as Record<string, string>; - if (params?.interval !== 'auto') - return search.aggs.parseInterval(params?.interval)?.asMilliseconds(); - - const { fromDate, toDate } = data.dateRange; - const duration = moment(toDate).diff(moment(fromDate)); - const targetMs = duration / histogramBarTarget; - return isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1); - } - return undefined; - } - const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); const isHistogramViz = filteredLayers.every((l) => l.isHistogram); @@ -386,7 +366,7 @@ export function XYChart({ ? { min: data.dateRange?.fromDate.getTime(), max: data.dateRange?.toDate.getTime(), - minInterval: calculateMinInterval(), + minInterval, } : undefined; @@ -802,6 +782,22 @@ export function XYChart({ ); } +function getFilteredLayers(layers: LayerArgs[], data: LensMultiTable) { + return layers.filter(({ layerId, xAccessor, accessors, splitAccessor }) => { + return !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) + ); + }); +} + function assertNever(x: never): never { throw new Error('Unexpected series type: ' + x); } diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 5e5eef2f01c177..ff719c222c5fa8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -7,7 +7,6 @@ import { CoreSetup, IUiSettingsClient } from 'kibana/public'; import moment from 'moment-timezone'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; -import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { LensPluginStartDependencies } from '../plugin'; @@ -63,7 +62,7 @@ export class XyVisualization { chartsThemeService: charts.theme, paletteService: palettes, timeZone: getTimeZone(core.uiSettings), - histogramBarTarget: core.uiSettings.get<number>(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + getIntervalByColumn: data.search.aggs.getDateMetaByDatatableColumn, }) ); return getXyVisualization({ paletteService: palettes, data }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts index 3150cb9975f216..ff39d91be7e4a1 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts @@ -46,3 +46,13 @@ export const getCreateExceptionListMinimalSchemaMockWithoutId = (): CreateExcept name: NAME, type: ENDPOINT_TYPE, }); + +/** + * Useful for end to end testing with detections + */ +export const getCreateExceptionListDetectionSchemaMock = (): CreateExceptionListSchema => ({ + description: DESCRIPTION, + list_id: LIST_ID, + name: NAME, + type: 'detection', +}); diff --git a/x-pack/plugins/maps/public/components/action_select.tsx b/x-pack/plugins/maps/public/components/action_select.tsx index ad61a6a129974c..8ea9334bba7533 100644 --- a/x-pack/plugins/maps/public/components/action_select.tsx +++ b/x-pack/plugins/maps/public/components/action_select.tsx @@ -8,6 +8,7 @@ import React, { Component } from 'react'; import { EuiFormRow, EuiSuperSelect, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { isUrlDrilldown } from '../trigger_actions/trigger_utils'; interface Props { value?: string; @@ -41,7 +42,7 @@ export class ActionSelect extends Component<Props, State> { } const actions = await this.props.getFilterActions(); if (this._isMounted) { - this.setState({ actions }); + this.setState({ actions: actions.filter((action) => !isUrlDrilldown(action)) }); } } diff --git a/x-pack/plugins/maps/public/connected_components/map_container/_map_container.scss b/x-pack/plugins/maps/public/connected_components/map_container/_map_container.scss index 2180573ef4583d..7ec7d0d47ed042 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/_map_container.scss +++ b/x-pack/plugins/maps/public/connected_components/map_container/_map_container.scss @@ -1,5 +1,4 @@ .mapMapWrapper { - background-color: $euiColorEmptyShade; position: relative; } diff --git a/x-pack/plugins/maps/public/connected_components/map_container/index.ts b/x-pack/plugins/maps/public/connected_components/map_container/index.ts index c4b5cc51fb210e..37ee3a630066d3 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_container/index.ts @@ -14,6 +14,7 @@ import { areLayersLoaded, getRefreshConfig, getMapInitError, + getMapSettings, getQueryableUniqueIndexPatternIds, isToolbarOverlayHidden, } from '../../selectors/map_selectors'; @@ -29,6 +30,7 @@ function mapStateToProps(state: MapStoreState) { mapInitError: getMapInitError(state), indexPatternIds: getQueryableUniqueIndexPatternIds(state), hideToolbarOverlay: isToolbarOverlayHidden(state), + backgroundColor: getMapSettings(state).backgroundColor, }; } 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 169875e63a5361..9a5110a0c24d24 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 @@ -23,7 +23,7 @@ import { LayerPanel } from '../layer_panel'; import { AddLayerPanel } from '../add_layer_panel'; import { ExitFullScreenButton } from '../../../../../../src/plugins/kibana_react/public'; import { getIndexPatternsFromIds } from '../../index_pattern_util'; -import { ES_GEO_FIELD_TYPE } from '../../../common/constants'; +import { ES_GEO_FIELD_TYPE, RawValue } from '../../../common/constants'; import { indexPatterns as indexPatternsUtils } from '../../../../../../src/plugins/data/public'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettingsPanel } from '../map_settings_panel'; @@ -37,8 +37,10 @@ const RENDER_COMPLETE_EVENT = 'renderComplete'; interface Props { addFilters: ((filters: Filter[]) => Promise<void>) | null; + backgroundColor: string; getFilterActions?: () => Promise<Action[]>; getActionContext?: () => ActionExecutionContext; + onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void; areLayersLoaded: boolean; cancelAllInFlightRequests: () => void; exitFullScreen: () => void; @@ -190,6 +192,7 @@ export class MapContainer extends Component<Props, State> { addFilters, getFilterActions, getActionContext, + onSingleValueTrigger, flyoutDisplay, isFullScreen, exitFullScreen, @@ -241,11 +244,15 @@ export class MapContainer extends Component<Props, State> { data-title={this.props.title} data-description={this.props.description} > - <EuiFlexItem className="mapMapWrapper"> + <EuiFlexItem + className="mapMapWrapper" + style={{ backgroundColor: this.props.backgroundColor }} + > <MBMap addFilters={addFilters} getFilterActions={getFilterActions} getActionContext={getActionContext} + onSingleValueTrigger={onSingleValueTrigger} geoFields={this.state.geoFields} renderTooltipContent={renderTooltipContent} /> diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_chrome_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_chrome_panel.tsx new file mode 100644 index 00000000000000..09e3d270fcf2cb --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_chrome_panel.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiPanel, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MapSettings } from '../../reducers/map'; +import { MbValidatedColorPicker } from '../../classes/styles/vector/components/color/mb_validated_color_picker'; + +interface Props { + settings: MapSettings; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; +} + +export function MapChromePanel({ settings, updateMapSetting }: Props) { + const onBackgroundColorChange = (color: string) => { + updateMapSetting('backgroundColor', color); + }; + + return ( + <EuiPanel> + <EuiTitle size="xs"> + <h5> + <FormattedMessage id="xpack.maps.mapSettingsPanel.mapTitle" defaultMessage="Map" /> + </h5> + </EuiTitle> + + <EuiFormRow + label={i18n.translate('xpack.maps.mapSettingsPanel.backgroundColorLabel', { + defaultMessage: 'Background color', + })} + display="columnCompressed" + > + <MbValidatedColorPicker + color={settings.backgroundColor} + onChange={onBackgroundColorChange} + /> + </EuiFormRow> + </EuiPanel> + ); +} diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index 5bc06031f35164..02461a6c0ba5c8 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -20,6 +20,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { NavigationPanel } from './navigation_panel'; import { SpatialFiltersPanel } from './spatial_filters_panel'; +import { MapChromePanel } from './map_chrome_panel'; import { MapCenter } from '../../../common/descriptor_types'; interface Props { @@ -65,6 +66,8 @@ export function MapSettingsPanel({ <div className="mapLayerPanel__body"> <div className="mapLayerPanel__bodyOverflow"> + <MapChromePanel settings={settings} updateMapSetting={updateMapSetting} /> + <EuiSpacer size="s" /> <NavigationPanel center={center} settings={settings} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js index edd501f2666907..97b47358ec089c 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js @@ -15,6 +15,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public'; +import { isUrlDrilldown } from '../../../trigger_actions/trigger_utils'; export class FeatureProperties extends React.Component { state = { @@ -114,21 +115,37 @@ export class FeatureProperties extends React.Component { _renderFilterActions(tooltipProperty) { const panel = { id: 0, - items: this.state.actions.map((action) => { - const actionContext = this.props.getActionContext(); - const iconType = action.getIconType(actionContext); - const name = action.getDisplayName(actionContext); - return { - name, - icon: iconType ? <EuiIcon type={iconType} /> : null, - onClick: async () => { - this.props.onCloseTooltip(); - const filters = await tooltipProperty.getESFilters(); - this.props.addFilters(filters, action.id); - }, - ['data-test-subj']: `mapFilterActionButton__${name}`, - }; - }), + items: this.state.actions + .filter((action) => { + if (isUrlDrilldown(action)) { + return !!this.props.onSingleValueTrigger; + } + return true; + }) + .map((action) => { + const actionContext = this.props.getActionContext(); + const iconType = action.getIconType(actionContext); + const name = action.getDisplayName(actionContext); + return { + name: name ? name : action.id, + icon: iconType ? <EuiIcon type={iconType} /> : null, + onClick: async () => { + this.props.onCloseTooltip(); + + if (isUrlDrilldown(action)) { + this.props.onSingleValueTrigger( + action.id, + tooltipProperty.getPropertyKey(), + tooltipProperty.getRawValue() + ); + } else { + const filters = await tooltipProperty.getESFilters(); + this.props.addFilters(filters, action.id); + } + }, + ['data-test-subj']: `mapFilterActionButton__${name}`, + }; + }), }; return ( diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js index 8547219b42e30d..60d9e57d15e23a 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js @@ -183,6 +183,7 @@ export class FeaturesTooltip extends Component { addFilters={this.props.addFilters} getFilterActions={this.props.getFilterActions} getActionContext={this.props.getActionContext} + onSingleValueTrigger={this.props.onSingleValueTrigger} showFilterActions={this._showFilterActionsView} /> {this._renderActions(geoFields)} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js index 04c376a093623b..0ea40f6e3182f4 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js @@ -323,6 +323,7 @@ export class MBMap extends React.Component { addFilters={this.props.addFilters} getFilterActions={this.props.getFilterActions} getActionContext={this.props.getActionContext} + onSingleValueTrigger={this.props.onSingleValueTrigger} geoFields={this.props.geoFields} renderTooltipContent={this.props.renderTooltipContent} /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js index b178eef6fa5d39..c5c3ad4d78f7e7 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js @@ -201,6 +201,7 @@ export class TooltipControl extends React.Component { addFilters={this.props.addFilters} getFilterActions={this.props.getFilterActions} getActionContext={this.props.getActionContext} + onSingleValueTrigger={this.props.onSingleValueTrigger} renderTooltipContent={this.props.renderTooltipContent} geoFields={this.props.geoFields} features={features} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js index ca4864f79940ee..4983e394ed93cc 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js @@ -119,6 +119,7 @@ export class TooltipPopover extends Component { addFilters: this.props.addFilters, getFilterActions: this.props.getFilterActions, getActionContext: this.props.getActionContext, + onSingleValueTrigger: this.props.onSingleValueTrigger, closeTooltip: this.props.closeTooltip, features: this.props.features, isLocked: this.props.isLocked, diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index caf21431145d56..7aaabc427790af 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -18,6 +18,7 @@ import { import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public'; import { APPLY_FILTER_TRIGGER, + VALUE_CLICK_TRIGGER, ActionExecutionContext, TriggerContextMapping, } from '../../../../../src/plugins/ui_actions/public'; @@ -57,6 +58,7 @@ import { getExistingMapPath, MAP_SAVED_OBJECT_TYPE, MAP_PATH, + RawValue, } from '../../common/constants'; import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; import { getUiActions, getCoreI18n, getHttp } from '../kibana_services'; @@ -65,6 +67,7 @@ import { MapContainer } from '../connected_components/map_container'; import { SavedMap } from '../routes/map_page'; import { getIndexPatternsFromIds } from '../index_pattern_util'; import { getMapAttributeService } from '../map_attribute_service'; +import { isUrlDrilldown, toValueClickDataFormat } from '../trigger_actions/trigger_utils'; import { MapByValueInput, @@ -202,7 +205,7 @@ export class MapEmbeddable } public supportedTriggers(): Array<keyof TriggerContextMapping> { - return [APPLY_FILTER_TRIGGER]; + return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER]; } setRenderTooltipContent = (renderTooltipContent: RenderToolTipContent) => { @@ -290,6 +293,7 @@ export class MapEmbeddable <Provider store={this._savedMap.getStore()}> <I18nContext> <MapContainer + onSingleValueTrigger={this.onSingleValueTrigger} addFilters={this.input.hideFilterActions ? null : this.addFilters} getFilterActions={this.getFilterActions} getActionContext={this.getActionContext} @@ -320,6 +324,20 @@ export class MapEmbeddable return await getIndexPatternsFromIds(queryableIndexPatternIds); } + onSingleValueTrigger = (actionId: string, key: string, value: RawValue) => { + const action = getUiActions().getAction(actionId); + if (!action) { + throw new Error('Unable to apply action, could not locate action'); + } + const executeContext = { + ...this.getActionContext(), + data: { + data: toValueClickDataFormat(key, value), + }, + }; + action.execute(executeContext); + }; + addFilters = async (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => { const executeContext = { ...this.getActionContext(), @@ -333,10 +351,24 @@ export class MapEmbeddable }; getFilterActions = async () => { - return await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, { + const filterActions = await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, { embeddable: this, filters: [], }); + const valueClickActions = await getUiActions().getTriggerCompatibleActions( + VALUE_CLICK_TRIGGER, + { + embeddable: this, + data: { + // uiActions.getTriggerCompatibleActions validates action with provided context + // so if event.key and event.value are used in the URL template but can not be parsed from context + // then the action is filtered out. + // To prevent filtering out actions, provide dummy context when initially fetching actions. + data: toValueClickDataFormat('anyfield', 'anyvalue'), + }, + } + ); + return [...filterActions, ...valueClickActions.filter(isUrlDrilldown)]; }; getActionContext = () => { diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index 896ac11e367821..e98af6f426b5a0 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { autoFitToDataBounds: false, + backgroundColor: euiThemeVars.euiColorEmptyShade, initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, fixedLocation: { lat: 0, lon: 0, zoom: 2 }, browserLocation: { zoom: 2 }, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index aca75334032d93..d4ac20c7114dcb 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -43,6 +43,7 @@ export type MapContext = { export type MapSettings = { autoFitToDataBounds: boolean; + backgroundColor: string; initialLocation: INITIAL_LOCATION; fixedLocation: { lat: number; diff --git a/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts b/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts new file mode 100644 index 00000000000000..3505588a9c0497 --- /dev/null +++ b/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'src/plugins/ui_actions/public'; +import { RawValue } from '../../common/constants'; +import { DatatableColumnType } from '../../../../../src/plugins/expressions'; + +export function isUrlDrilldown(action: Action) { + // @ts-expect-error + return action.type === 'URL_DRILLDOWN'; +} + +// VALUE_CLICK_TRIGGER is coupled with expressions and Datatable type +// URL drilldown parses event scope from Datatable +// https://github.com/elastic/kibana/blob/7.10/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts#L140 +// In order to use URL drilldown, maps has to package its data in Datatable compatiable format. +export function toValueClickDataFormat(key: string, value: RawValue) { + return [ + { + table: { + columns: [ + { + id: key, + meta: { + type: 'unknown' as DatatableColumnType, // type is not used by URL drilldown to parse event but is required by DatatableColumnMeta + field: key, + }, + name: key, + }, + ], + rows: [ + { + [key]: value, + }, + ], + }, + column: 0, + row: 0, + value, + }, + ]; +} diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index 958d5ae250185c..7eef86869b9e5f 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -16,7 +16,7 @@ export const JOB_MAP_NODE_TYPES = { ANALYTICS: 'analytics', TRANSFORM: 'transform', INDEX: 'index', - INFERENCE_MODEL: 'inferenceModel', + TRAINED_MODEL: 'trainedModel', } as const; export type JobMapNodeTypes = typeof JOB_MAP_NODE_TYPES[keyof typeof JOB_MAP_NODE_TYPES]; diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 9a3d8fc4a4f021..b5a78ee746efe2 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -156,6 +156,7 @@ export type TimeSeriesExplorerUrlState = MLPageState< export interface DataFrameAnalyticsQueryState { jobId?: JobId | JobId[]; + modelId?: string; groupIds?: string[]; globalState?: MlCommonGlobalState; } @@ -170,6 +171,7 @@ export interface DataFrameAnalyticsExplorationQueryState { jobId: JobId; analysisType: DataFrameAnalysisConfigType; defaultIsTraining?: boolean; + modelId?: string; }; } @@ -180,6 +182,7 @@ export type DataFrameAnalyticsExplorationUrlState = MLPageState< analysisType: DataFrameAnalysisConfigType; globalState?: MlCommonGlobalState; defaultIsTraining?: boolean; + modelId?: string; } >; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx index a5d3555fcc2788..bf90ce58fb85d4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx @@ -15,10 +15,11 @@ interface Tab { path: string; } -export const AnalyticsNavigationBar: FC<{ selectedTabId?: string; jobId?: string }> = ({ - jobId, - selectedTabId, -}) => { +export const AnalyticsNavigationBar: FC<{ + selectedTabId?: string; + jobId?: string; + modelId?: string; +}> = ({ jobId, modelId, selectedTabId }) => { const navigateToPath = useNavigateToPath(); const tabs = useMemo(() => { @@ -38,7 +39,7 @@ export const AnalyticsNavigationBar: FC<{ selectedTabId?: string; jobId?: string path: '/data_frame_analytics/models', }, ]; - if (jobId !== undefined) { + if (jobId !== undefined || modelId !== undefined) { navTabs.push({ id: 'map', name: i18n.translate('xpack.ml.dataframe.mapTabLabel', { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index 2d74d08c4550c7..cde29d357b1c62 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -342,7 +342,7 @@ export const ModelsList: FC = () => { onClick: async (item) => { const path = await mlUrlGenerator.createUrl({ page: ML_PAGES.DATA_FRAME_ANALYTICS_MAP, - pageState: { jobId: item.metadata?.analytics_config.id }, + pageState: { modelId: item.model_id }, }); await navigateToPath(path, false); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index 5a17b91818a1c4..38b7088690e12d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -59,6 +59,7 @@ export const Page: FC = () => { const location = useLocation(); const selectedTabId = useMemo(() => location.pathname.split('/').pop(), [location]); const mapJobId = globalState?.ml?.jobId; + const mapModelId = globalState?.ml?.modelId; return ( <Fragment> @@ -106,8 +107,14 @@ export const Page: FC = () => { <UpgradeWarning /> <EuiPageContent> - <AnalyticsNavigationBar selectedTabId={selectedTabId} jobId={mapJobId} /> - {selectedTabId === 'map' && mapJobId && <JobMap analyticsId={mapJobId} />} + <AnalyticsNavigationBar + selectedTabId={selectedTabId} + jobId={mapJobId} + modelId={mapModelId} + /> + {selectedTabId === 'map' && (mapJobId || mapModelId) && ( + <JobMap analyticsId={mapJobId} modelId={mapModelId} /> + )} {selectedTabId === 'data_frame_analytics' && ( <DataFrameAnalyticsList blockRefresh={blockRefresh} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss index d54b5214f7448e..7fcd082a372301 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss @@ -5,7 +5,7 @@ .mlJobMapLegend__indexPattern { height: $euiSizeM; width: $euiSizeM; - background-color: '#FFFFFF'; + background-color: $euiColorGhost; border: 1px solid $euiColorVis2; transform: rotate(45deg); display: 'inline-block'; @@ -14,7 +14,7 @@ .mlJobMapLegend__transform { height: $euiSizeM; width: $euiSizeM; - background-color: '#FFFFFF'; + background-color: $euiColorGhost; border: 1px solid $euiColorVis1; display: 'inline-block'; } @@ -22,17 +22,26 @@ .mlJobMapLegend__analytics { height: $euiSizeM; width: $euiSizeM; - background-color: '#FFFFFF'; + background-color: $euiColorGhost; border: 1px solid $euiColorVis0; - border-radius: 50%; + border-radius: $euiBorderRadius; display: 'inline-block'; } -.mlJobMapLegend__inferenceModel { +.mlJobMapLegend__trainedModel { height: $euiSizeM; width: $euiSizeM; - background-color: '#FFFFFF'; - border: 1px solid $euiColorMediumShade; - border-radius: 50%; + background-color: $euiColorGhost; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + display: 'inline-block'; +} + +.mlJobMapLegend__sourceNode { + height: $euiSizeM; + width: $euiSizeM; + background-color: $euiColorLightShade; + border: $euiBorderThin; + border-radius: $euiBorderRadius; display: 'inline-block'; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx index ed25ea6cbf02c0..f5738c20b2c3fd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -25,10 +25,11 @@ import { EuiDescriptionListProps } from '@elastic/eui/src/components/description import { CytoscapeContext } from './cytoscape'; import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/util/date_utils'; import { JOB_MAP_NODE_TYPES } from '../../../../../../common/constants/data_frame_analytics'; -// import { DeleteButton } from './delete_button'; +// import { DeleteButton } from './delete_button'; // TODO: add delete functionality in followup interface Props { - analyticsId: string; + analyticsId?: string; + modelId?: string; details: any; getNodeData: any; } @@ -56,7 +57,7 @@ function getListItems(details: object): EuiDescriptionListProps['listItems'] { }); } -export const Controls: FC<Props> = ({ analyticsId, details, getNodeData }) => { +export const Controls: FC<Props> = ({ analyticsId, modelId, details, getNodeData }) => { const [showFlyout, setShowFlyout] = useState<boolean>(false); const [selectedNode, setSelectedNode] = useState<cytoscape.NodeSingular | undefined>(); @@ -98,10 +99,12 @@ export const Controls: FC<Props> = ({ analyticsId, details, getNodeData }) => { } const nodeDataButton = - analyticsId !== nodeLabel && nodeType === JOB_MAP_NODE_TYPES.ANALYTICS ? ( + analyticsId !== nodeLabel && + modelId !== nodeLabel && + (nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX) ? ( <EuiButtonEmpty onClick={() => { - getNodeData(nodeLabel); + getNodeData({ id: nodeLabel, type: nodeType }); setShowFlyout(false); }} iconType="branch" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx index 85d10aa897415d..18be614afb5c32 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx @@ -80,7 +80,8 @@ export const cytoscapeOptions: cytoscape.CytoscapeOptions = { { selector: 'node', style: { - 'background-color': theme.euiColorGhost, + 'background-color': (el: cytoscape.NodeSingular) => + el.data('isRoot') ? theme.euiColorLightShade : theme.euiColorGhost, 'background-height': '60%', 'background-width': '60%', 'border-color': (el: cytoscape.NodeSingular) => borderColorForNode(el), diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx index c29b6aca804d70..04e415eca16918 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx @@ -6,6 +6,7 @@ import React, { FC } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { JOB_MAP_NODE_TYPES } from '../../../../../../common/constants/data_frame_analytics'; export const JobMapLegend: FC = () => ( @@ -17,7 +18,10 @@ export const JobMapLegend: FC = () => ( </EuiFlexItem> <EuiFlexItem grow={false}> <EuiText size="xs" color="subdued"> - {JOB_MAP_NODE_TYPES.INDEX} + <FormattedMessage + id="xpack.ml.dataframe.analyticsMap.legend.indexLabel" + defaultMessage="index" + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -41,7 +45,10 @@ export const JobMapLegend: FC = () => ( </EuiFlexItem> <EuiFlexItem grow={false}> <EuiText size="xs" color="subdued"> - {JOB_MAP_NODE_TYPES.ANALYTICS} + <FormattedMessage + id="xpack.ml.dataframe.analyticsMap.legend.analyticsJobLabel" + defaultMessage="analytics job" + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -49,11 +56,29 @@ export const JobMapLegend: FC = () => ( <EuiFlexItem grow={false}> <EuiFlexGroup gutterSize="xs" alignItems="center"> <EuiFlexItem grow={false}> - <span className="mlJobMapLegend__inferenceModel" /> + <span className="mlJobMapLegend__trainedModel" /> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiText size="xs" color="subdued"> - {'inference model'} + <FormattedMessage + id="xpack.ml.dataframe.analyticsMap.legend.trainedModelLabel" + defaultMessage="trained model" + /> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup gutterSize="xs" alignItems="center"> + <EuiFlexItem grow={false}> + <span className="mlJobMapLegend__sourceNode" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText size="xs" color="subdued"> + <FormattedMessage + id="xpack.ml.dataframe.analyticsMap.legend.rootNodeLabel" + defaultMessage="source node" + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx index 53d47937409d81..6395d491d5e6b2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx @@ -15,6 +15,7 @@ import { Cytoscape, Controls, JobMapLegend } from './components'; import { ml } from '../../../services/ml_api_service'; import { useMlKibana } from '../../../contexts/kibana'; import { useRefDimensions } from './components/use_ref_dimensions'; +import { JOB_MAP_NODE_TYPES } from '../../../../../common/constants/data_frame_analytics'; const cytoscapeDivStyle = { background: `linear-gradient( @@ -36,22 +37,36 @@ ${theme.euiColorLightShade}`, marginTop: 0, }; -export const JobMapTitle: React.FC<{ analyticsId: string }> = ({ analyticsId }) => ( +export const JobMapTitle: React.FC<{ analyticsId?: string; modelId?: string }> = ({ + analyticsId, + modelId, +}) => ( <EuiTitle size="xs"> <span> - {i18n.translate('xpack.ml.dataframe.analyticsMap.analyticsIdTitle', { - defaultMessage: 'Map for analytics ID {analyticsId}', - values: { analyticsId }, - })} + {analyticsId + ? i18n.translate('xpack.ml.dataframe.analyticsMap.analyticsIdTitle', { + defaultMessage: 'Map for analytics ID {analyticsId}', + values: { analyticsId }, + }) + : i18n.translate('xpack.ml.dataframe.analyticsMap.modelIdTitle', { + defaultMessage: 'Map for trained model ID {modelId}', + values: { modelId }, + })} </span> </EuiTitle> ); +interface GetDataObjectParameter { + id: string; + type: string; +} + interface Props { - analyticsId: string; + analyticsId?: string; + modelId?: string; } -export const JobMap: FC<Props> = ({ analyticsId }) => { +export const JobMap: FC<Props> = ({ analyticsId, modelId }) => { const [elements, setElements] = useState<cytoscape.ElementDefinition[]>([]); const [nodeDetails, setNodeDetails] = useState({}); const [error, setError] = useState(undefined); @@ -60,14 +75,33 @@ export const JobMap: FC<Props> = ({ analyticsId }) => { services: { notifications }, } = useMlKibana(); - const getData = async (id?: string) => { + const getDataWrapper = async (params?: GetDataObjectParameter) => { + const { id, type } = params ?? {}; const treatAsRoot = id !== undefined; - const idToUse = treatAsRoot ? id : analyticsId; - // Pass in treatAsRoot flag - endpoint will take job destIndex to grab jobs created from it + let idToUse: string; + + if (id !== undefined) { + idToUse = id; + } else if (modelId !== undefined) { + idToUse = modelId; + } else { + idToUse = analyticsId as string; + } + + await getData( + idToUse, + treatAsRoot, + modelId !== undefined && treatAsRoot === false ? JOB_MAP_NODE_TYPES.TRAINED_MODEL : type + ); + }; + + const getData = async (idToUse: string, treatAsRoot: boolean, type?: string) => { + // Pass in treatAsRoot flag - endpoint will take job or index to grab jobs created from it // TODO: update analyticsMap return type here const analyticsMap: any = await ml.dataFrameAnalytics.getDataFrameAnalyticsMap( idToUse, - treatAsRoot + treatAsRoot, + type ); const { elements: nodeElements, details, error: fetchError } = analyticsMap; @@ -86,7 +120,7 @@ export const JobMap: FC<Props> = ({ analyticsId }) => { } if (nodeElements && nodeElements.length > 0) { - if (id === undefined) { + if (treatAsRoot === false) { setElements(nodeElements); setNodeDetails(details); } else { @@ -98,8 +132,8 @@ export const JobMap: FC<Props> = ({ analyticsId }) => { }; useEffect(() => { - getData(); - }, [analyticsId]); + getDataWrapper(); + }, [analyticsId, modelId]); if (error !== undefined) { notifications.toasts.addDanger( @@ -119,14 +153,19 @@ export const JobMap: FC<Props> = ({ analyticsId }) => { <div style={{ height: height - parseInt(theme.gutterTypes.gutterLarge, 10) }} ref={ref}> <EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexItem grow={false}> - <JobMapTitle analyticsId={analyticsId} /> + <JobMapTitle analyticsId={analyticsId} modelId={modelId} /> </EuiFlexItem> <EuiFlexItem grow={false}> <JobMapLegend /> </EuiFlexItem> </EuiFlexGroup> <Cytoscape height={height} elements={elements} width={width} style={cytoscapeDivStyle}> - <Controls details={nodeDetails} getNodeData={getData} analyticsId={analyticsId} /> + <Controls + details={nodeDetails} + getNodeData={getDataWrapper} + analyticsId={analyticsId} + modelId={modelId} + /> </Cytoscape> </div> </> diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 21556a4702b4e5..8e541443c34a13 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -83,12 +83,12 @@ export const dataFrameAnalytics = { body, }); }, - getDataFrameAnalyticsMap(analyticsId?: string, treatAsRoot?: boolean) { - const analyticsIdString = analyticsId !== undefined ? `/${analyticsId}` : ''; + getDataFrameAnalyticsMap(id: string, treatAsRoot: boolean, type?: string) { + const idString = id !== undefined ? `/${id}` : ''; return http({ - path: `${basePath()}/data_frame/analytics/map${analyticsIdString}`, + path: `${basePath()}/data_frame/analytics/map${idString}`, method: 'GET', - query: { treatAsRoot }, + query: { treatAsRoot, type }, }); }, evaluateDataFrameAnalytics(evaluateConfig: any) { diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts index dc9c3bd86cc63c..10764022a3ce76 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts @@ -104,11 +104,12 @@ export function createDataFrameAnalyticsMapUrl( let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_MAP}`; if (mlUrlGeneratorState) { - const { jobId, analysisType, defaultIsTraining, globalState } = mlUrlGeneratorState; + const { jobId, modelId, analysisType, defaultIsTraining, globalState } = mlUrlGeneratorState; const queryState: DataFrameAnalyticsExplorationQueryState = { ml: { jobId, + modelId, analysisType, defaultIsTraining, }, diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index f1f0b352ca9207..769ec09a6b9115 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -10,12 +10,17 @@ import { JOB_MAP_NODE_TYPES, JobMapNodeTypes, } from '../../../common/constants/data_frame_analytics'; +import { TrainedModelConfigResponse } from '../../../common/types/trained_models'; import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer'; import { getAnalysisType } from '../../../common/util/analytics_utils'; import { AnalyticsMapEdgeElement, AnalyticsMapReturnType, AnalyticsMapNodeElement, + ExtendAnalyticsMapArgs, + GetAnalyticsMapArgs, + InitialElementsReturnType, + isCompleteInitialReturnType, isAnalyticsMapEdgeElement, isAnalyticsMapNodeElement, isIndexPatternLinkReturnType, @@ -29,7 +34,7 @@ import type { MlClient } from '../../lib/ml_client'; export class AnalyticsManager { private _client: IScopedClusterClient['asInternalUser']; private _mlClient: MlClient; - public _inferenceModels: any; // TODO: update types + public _inferenceModels: TrainedModelConfigResponse[]; constructor(mlClient: MlClient, client: IScopedClusterClient['asInternalUser']) { this._client = client; @@ -37,11 +42,11 @@ export class AnalyticsManager { this._inferenceModels = []; } - public set inferenceModels(models: any) { + public set inferenceModels(models) { this._inferenceModels = models; } - public get inferenceModels(): any { + public get inferenceModels() { return this._inferenceModels; } @@ -56,16 +61,20 @@ export class AnalyticsManager { } } - private isDuplicateElement(analyticsId: string, elements: any[]): boolean { + private isDuplicateElement(analyticsId: string, elements: MapElements[]): boolean { let isDuplicate = false; - elements.forEach((elem: any) => { - if (elem.data.label === analyticsId && elem.data.type === JOB_MAP_NODE_TYPES.ANALYTICS) { + elements.forEach((elem) => { + if ( + isAnalyticsMapNodeElement(elem) && + elem.data.label === analyticsId && + elem.data.type === JOB_MAP_NODE_TYPES.ANALYTICS + ) { isDuplicate = true; } }); return isDuplicate; } - // @ts-ignore // TODO: is this needed? + private async getAnalyticsModelData(modelId: string) { const resp = await this._mlClient.getTrainedModels({ model_id: modelId, @@ -80,11 +89,17 @@ export class AnalyticsManager { return models; } - private async getAnalyticsJobData(analyticsId: string) { - const resp = await this._mlClient.getDataFrameAnalytics({ - id: analyticsId, - }); - const jobData = resp?.body?.data_frame_analytics[0]; + private async getAnalyticsData(analyticsId?: string) { + const options = analyticsId + ? { + id: analyticsId, + } + : undefined; + const resp = await this._mlClient.getDataFrameAnalytics(options); + const jobData = analyticsId + ? resp?.body?.data_frame_analytics[0] + : resp?.body?.data_frame_analytics; + return jobData; } @@ -130,7 +145,7 @@ export class AnalyticsManager { return { isWildcardIndexPattern, isIndexPattern: true, indexData, meta }; } else if (type.includes(JOB_MAP_NODE_TYPES.ANALYTICS)) { // fetch job associated with this index - const jobData = await this.getAnalyticsJobData(id); + const jobData = await this.getAnalyticsData(id); return { jobData, isJob: true }; } else if (type === JOB_MAP_NODE_TYPES.TRANSFORM) { // fetch transform so we can get original index pattern @@ -155,12 +170,12 @@ export class AnalyticsManager { let edgeElement; if (analyticsModel !== undefined) { - const modelId = `${analyticsModel.model_id}-${JOB_MAP_NODE_TYPES.INFERENCE_MODEL}`; + const modelId = `${analyticsModel.model_id}-${JOB_MAP_NODE_TYPES.TRAINED_MODEL}`; modelElement = { data: { id: modelId, label: analyticsModel.model_id, - type: JOB_MAP_NODE_TYPES.INFERENCE_MODEL, + type: JOB_MAP_NODE_TYPES.TRAINED_MODEL, }, }; // Create edge for job and corresponding model @@ -201,29 +216,41 @@ export class AnalyticsManager { } /** - * Works backward from jobId to return related jobs from source indices - * @param jobId + * Prepares the initial elements for incoming modelId + * @param modelId */ - async getAnalyticsMap(analyticsId: string): Promise<AnalyticsMapReturnType> { - const result: any = { elements: [], details: {}, error: null }; - const modelElements: MapElements[] = []; - const indexPatternElements: MapElements[] = []; + async getInitialElementsModelRoot(modelId: string): Promise<InitialElementsReturnType> { + const resultElements = []; + const modelElements = []; + const details: any = {}; + // fetch model data and create model elements + let data = await this.getAnalyticsModelData(modelId); + const modelNodeId = `${data.model_id}-${JOB_MAP_NODE_TYPES.TRAINED_MODEL}`; + const sourceJobId = data?.metadata?.analytics_config?.id; + let nextLinkId: string | undefined; + let nextType: JobMapNodeTypes | undefined; + let previousNodeId: string | undefined; + + modelElements.push({ + data: { + id: modelNodeId, + label: data.model_id, + type: JOB_MAP_NODE_TYPES.TRAINED_MODEL, + isRoot: true, + }, + }); - try { - await this.setInferenceModels(); - // Create first node for incoming analyticsId - let data = await this.getAnalyticsJobData(analyticsId); - let nextLinkId = data?.source?.index[0]; - let nextType: JobMapNodeTypes = JOB_MAP_NODE_TYPES.INDEX; - let complete = false; - let link: NextLinkReturnType; - let count = 0; - let rootTransform; - let rootIndexPattern; - - let previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + details[modelNodeId] = data; + // fetch source job data and create elements + if (sourceJobId !== undefined) { + data = await this.getAnalyticsData(sourceJobId); - result.elements.push({ + nextLinkId = data?.source?.index[0]; + nextType = JOB_MAP_NODE_TYPES.INDEX; + + previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + + resultElements.push({ data: { id: previousNodeId, label: data.id, @@ -231,167 +258,178 @@ export class AnalyticsManager { analysisType: getAnalysisType(data?.analysis), }, }); - result.details[previousNodeId] = data; + // Create edge between job and model + modelElements.push({ + data: { + id: `${previousNodeId}~${modelNodeId}`, + source: previousNodeId, + target: modelNodeId, + }, + }); - let { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(analyticsId); - if (isAnalyticsMapNodeElement(modelElement)) { - modelElements.push(modelElement); - result.details[modelElement.data.id] = modelDetails; - } - if (isAnalyticsMapEdgeElement(edgeElement)) { - modelElements.push(edgeElement); - } - // Add a safeguard against infinite loops. - while (complete === false) { - count++; - if (count >= 100) { - break; - } + details[previousNodeId] = data; + } - try { - link = await this.getNextLink({ - id: nextLinkId, - type: nextType, - }); - } catch (error) { - result.error = error.message || 'Something went wrong'; - break; - } - // If it's index pattern, check meta data to see what to fetch next - if (isIndexPatternLinkReturnType(link) && link.isIndexPattern === true) { - if (link.isWildcardIndexPattern === true) { - // Create index nodes for each of the indices included in the index pattern then break - const { details, elements } = this.getIndexPatternElements( - link.indexData, - previousNodeId - ); - - indexPatternElements.push(...elements); - result.details = { ...result.details, ...details }; - complete = true; - } else { - const nodeId = `${nextLinkId}-${JOB_MAP_NODE_TYPES.INDEX}`; - result.elements.unshift({ - data: { id: nodeId, label: nextLinkId, type: JOB_MAP_NODE_TYPES.INDEX }, - }); - result.details[nodeId] = link.indexData; - } + return { data, details, resultElements, modelElements, nextLinkId, nextType, previousNodeId }; + } - // Check meta data - if ( - link.isWildcardIndexPattern === false && - (link.meta === undefined || link.meta?.created_by === INDEX_META_DATA_CREATED_BY) - ) { - rootIndexPattern = nextLinkId; - complete = true; - break; - } + /** + * Prepares the initial elements for incoming jobId + * @param jobId + */ + async getInitialElementsJobRoot(jobId: string): Promise<InitialElementsReturnType> { + const resultElements = []; + const modelElements = []; + const details: any = {}; + const data = await this.getAnalyticsData(jobId); + const nextLinkId = data?.source?.index[0]; + const nextType: JobMapNodeTypes = JOB_MAP_NODE_TYPES.INDEX; + + const previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + + resultElements.push({ + data: { + id: previousNodeId, + label: data.id, + type: JOB_MAP_NODE_TYPES.ANALYTICS, + analysisType: getAnalysisType(data?.analysis), + isRoot: true, + }, + }); - if (link.meta?.created_by === 'data-frame-analytics') { - nextLinkId = link.meta.analytics; - nextType = JOB_MAP_NODE_TYPES.ANALYTICS; - } + details[previousNodeId] = data; - if (link.meta?.created_by === JOB_MAP_NODE_TYPES.TRANSFORM) { - nextLinkId = link.meta._transform?.transform; - nextType = JOB_MAP_NODE_TYPES.TRANSFORM; - } - } else if (isJobDataLinkReturnType(link) && link.isJob === true) { - data = link.jobData; - const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; - previousNodeId = nodeId; + const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(jobId); + if (isAnalyticsMapNodeElement(modelElement)) { + modelElements.push(modelElement); + details[modelElement.data.id] = modelDetails; + } + if (isAnalyticsMapEdgeElement(edgeElement)) { + modelElements.push(edgeElement); + } - result.elements.unshift({ - data: { - id: nodeId, - label: data.id, - type: JOB_MAP_NODE_TYPES.ANALYTICS, - analysisType: getAnalysisType(data?.analysis), - }, - }); - result.details[nodeId] = data; - nextLinkId = data?.source?.index[0]; - nextType = JOB_MAP_NODE_TYPES.INDEX; - - // Get inference model for analytics job and create model node - ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(data.id)); - if (isAnalyticsMapNodeElement(modelElement)) { - modelElements.push(modelElement); - result.details[modelElement.data.id] = modelDetails; - } - if (isAnalyticsMapEdgeElement(edgeElement)) { - modelElements.push(edgeElement); + return { data, details, resultElements, modelElements, nextLinkId, nextType, previousNodeId }; + } + + /** + * Works backward from jobId or modelId to return related jobs, indices, models, and transforms + * @param jobId (optional) + * @param modelId (optional) + */ + async getAnalyticsMap({ + analyticsId, + modelId, + }: GetAnalyticsMapArgs): Promise<AnalyticsMapReturnType> { + const result: AnalyticsMapReturnType = { elements: [], details: {}, error: null }; + const modelElements: MapElements[] = []; + const indexPatternElements: MapElements[] = []; + + try { + await this.setInferenceModels(); + // Create first node for incoming analyticsId or modelId + let initialData: InitialElementsReturnType = {} as InitialElementsReturnType; + if (analyticsId !== undefined) { + initialData = await this.getInitialElementsJobRoot(analyticsId); + } else if (modelId !== undefined) { + initialData = await this.getInitialElementsModelRoot(modelId); + } + + const { + resultElements, + details: initialDetails, + modelElements: initialModelElements, + } = initialData; + + result.elements.push(...resultElements); + result.details = initialDetails; + modelElements.push(...initialModelElements); + + if (isCompleteInitialReturnType(initialData)) { + let { data, nextLinkId, nextType, previousNodeId } = initialData; + + let complete = false; + let link: NextLinkReturnType; + let count = 0; + let rootTransform; + let rootIndexPattern; + let modelElement; + let modelDetails; + let edgeElement; + + // Add a safeguard against infinite loops. + while (complete === false) { + count++; + if (count >= 100) { + break; } - } else if (isTransformLinkReturnType(link) && link.isTransform === true) { - data = link.transformData; - const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.TRANSFORM}`; - previousNodeId = nodeId; - rootTransform = data.dest.index; + try { + link = await this.getNextLink({ + id: nextLinkId, + type: nextType, + }); + } catch (error) { + result.error = error.message || 'Something went wrong'; + break; + } + // If it's index pattern, check meta data to see what to fetch next + if (isIndexPatternLinkReturnType(link) && link.isIndexPattern === true) { + if (link.isWildcardIndexPattern === true) { + // Create index nodes for each of the indices included in the index pattern then break + const { details, elements } = this.getIndexPatternElements( + link.indexData, + previousNodeId + ); + + indexPatternElements.push(...elements); + result.details = { ...result.details, ...details }; + complete = true; + } else { + const nodeId = `${nextLinkId}-${JOB_MAP_NODE_TYPES.INDEX}`; + result.elements.unshift({ + data: { id: nodeId, label: nextLinkId, type: JOB_MAP_NODE_TYPES.INDEX }, + }); + result.details[nodeId] = link.indexData; + } - result.elements.unshift({ - data: { id: nodeId, label: data.id, type: JOB_MAP_NODE_TYPES.TRANSFORM }, - }); - result.details[nodeId] = data; - nextLinkId = data?.source?.index[0]; - nextType = JOB_MAP_NODE_TYPES.INDEX; - } - } // end while + // Check meta data + if ( + link.isWildcardIndexPattern === false && + (link.meta === undefined || link.meta?.created_by === INDEX_META_DATA_CREATED_BY) + ) { + rootIndexPattern = nextLinkId; + complete = true; + break; + } - // create edge elements - const elemLength = result.elements.length - 1; - for (let i = 0; i < elemLength; i++) { - const currentElem = result.elements[i]; - const nextElem = result.elements[i + 1]; - if ( - currentElem !== undefined && - nextElem !== undefined && - currentElem?.data?.id.includes('*') === false && - nextElem?.data?.id.includes('*') === false - ) { - result.elements.push({ - data: { - id: `${currentElem.data.id}~${nextElem.data.id}`, - source: currentElem.data.id, - target: nextElem.data.id, - }, - }); - } - } + if (link.meta?.created_by === 'data-frame-analytics') { + nextLinkId = link.meta.analytics; + nextType = JOB_MAP_NODE_TYPES.ANALYTICS; + } - // fetch all jobs associated with root transform if defined, otherwise check root index - if (rootTransform !== undefined || rootIndexPattern !== undefined) { - const analyticsJobs = await this._mlClient.getDataFrameAnalytics(); - const jobs = analyticsJobs?.body?.data_frame_analytics || []; - const comparator = rootTransform !== undefined ? rootTransform : rootIndexPattern; + if (link.meta?.created_by === JOB_MAP_NODE_TYPES.TRANSFORM) { + nextLinkId = link.meta._transform?.transform; + nextType = JOB_MAP_NODE_TYPES.TRANSFORM; + } + } else if (isJobDataLinkReturnType(link) && link.isJob === true) { + data = link.jobData; + const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + previousNodeId = nodeId; - for (let i = 0; i < jobs.length; i++) { - if ( - jobs[i]?.source?.index[0] === comparator && - this.isDuplicateElement(jobs[i].id, result.elements) === false - ) { - const nodeId = `${jobs[i].id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; - result.elements.push({ + result.elements.unshift({ data: { id: nodeId, - label: jobs[i].id, + label: data.id, type: JOB_MAP_NODE_TYPES.ANALYTICS, - analysisType: getAnalysisType(jobs[i]?.analysis), - }, - }); - result.details[nodeId] = jobs[i]; - const source = `${comparator}-${JOB_MAP_NODE_TYPES.INDEX}`; - result.elements.push({ - data: { - id: `${source}~${nodeId}`, - source, - target: nodeId, + analysisType: getAnalysisType(data?.analysis), }, }); + result.details[nodeId] = data; + nextLinkId = data?.source?.index[0]; + nextType = JOB_MAP_NODE_TYPES.INDEX; + // Get inference model for analytics job and create model node - ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( - jobs[i].id - )); + ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(data.id)); if (isAnalyticsMapNodeElement(modelElement)) { modelElements.push(modelElement); result.details[modelElement.data.id] = modelDetails; @@ -399,12 +437,88 @@ export class AnalyticsManager { if (isAnalyticsMapEdgeElement(edgeElement)) { modelElements.push(edgeElement); } + } else if (isTransformLinkReturnType(link) && link.isTransform === true) { + data = link.transformData; + + const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.TRANSFORM}`; + previousNodeId = nodeId; + rootTransform = data.dest.index; + + result.elements.unshift({ + data: { id: nodeId, label: data.id, type: JOB_MAP_NODE_TYPES.TRANSFORM }, + }); + result.details[nodeId] = data; + nextLinkId = data?.source?.index[0]; + nextType = JOB_MAP_NODE_TYPES.INDEX; + } + } // end while + + // create edge elements + const elemLength = result.elements.length - 1; + for (let i = 0; i < elemLength; i++) { + const currentElem = result.elements[i]; + const nextElem = result.elements[i + 1]; + if ( + currentElem !== undefined && + nextElem !== undefined && + currentElem?.data?.id.includes('*') === false && + nextElem?.data?.id.includes('*') === false + ) { + result.elements.push({ + data: { + id: `${currentElem.data.id}~${nextElem.data.id}`, + source: currentElem.data.id, + target: nextElem.data.id, + }, + }); + } + } + + // fetch all jobs associated with root transform if defined, otherwise check root index + if (rootTransform !== undefined || rootIndexPattern !== undefined) { + const jobs = await this.getAnalyticsData(); + const comparator = rootTransform !== undefined ? rootTransform : rootIndexPattern; + + for (let i = 0; i < jobs.length; i++) { + if ( + jobs[i]?.source?.index[0] === comparator && + this.isDuplicateElement(jobs[i].id, result.elements) === false + ) { + const nodeId = `${jobs[i].id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + result.elements.push({ + data: { + id: nodeId, + label: jobs[i].id, + type: JOB_MAP_NODE_TYPES.ANALYTICS, + analysisType: getAnalysisType(jobs[i]?.analysis), + }, + }); + result.details[nodeId] = jobs[i]; + const source = `${comparator}-${JOB_MAP_NODE_TYPES.INDEX}`; + result.elements.push({ + data: { + id: `${source}~${nodeId}`, + source, + target: nodeId, + }, + }); + // Get inference model for analytics job and create model node + ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( + jobs[i].id + )); + if (isAnalyticsMapNodeElement(modelElement)) { + modelElements.push(modelElement); + result.details[modelElement.data.id] = modelDetails; + } + if (isAnalyticsMapEdgeElement(edgeElement)) { + modelElements.push(edgeElement); + } + } } } } // Include model and index pattern nodes in result elements now that all other nodes have been created result.elements.push(...modelElements, ...indexPatternElements); - return result; } catch (error) { result.error = error.message || 'An error occurred fetching map'; @@ -412,56 +526,64 @@ export class AnalyticsManager { } } - async extendAnalyticsMapForAnalyticsJob(analyticsId: string): Promise<AnalyticsMapReturnType> { - const result: any = { elements: [], details: {}, error: null }; - + async extendAnalyticsMapForAnalyticsJob({ + analyticsId, + index, + }: ExtendAnalyticsMapArgs): Promise<AnalyticsMapReturnType> { + const result: AnalyticsMapReturnType = { elements: [], details: {}, error: null }; try { await this.setInferenceModels(); + const jobs = await this.getAnalyticsData(); + let rootIndex; + let rootIndexNodeId; + + if (analyticsId !== undefined) { + const jobData = await this.getAnalyticsData(analyticsId); + const currentJobNodeId = `${jobData.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + rootIndex = Array.isArray(jobData?.dest?.index) + ? jobData?.dest?.index[0] + : jobData?.dest?.index; + rootIndexNodeId = `${rootIndex}-${JOB_MAP_NODE_TYPES.INDEX}`; + + // Fetch inference model for incoming job id and add node and edge + const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( + analyticsId + ); + if (isAnalyticsMapNodeElement(modelElement)) { + result.elements.push(modelElement); + result.details[modelElement.data.id] = modelDetails; + } + if (isAnalyticsMapEdgeElement(edgeElement)) { + result.elements.push(edgeElement); + } - const jobData = await this.getAnalyticsJobData(analyticsId); - const currentJobNodeId = `${jobData.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; - const destIndex = Array.isArray(jobData?.dest?.index) - ? jobData?.dest?.index[0] - : jobData?.dest?.index; - const destIndexNodeId = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`; - const analyticsJobs = await this._mlClient.getDataFrameAnalytics(); - const jobs = analyticsJobs?.body?.data_frame_analytics || []; - - // Fetch inference model for incoming job id and add node and edge - const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( - analyticsId - ); - if (isAnalyticsMapNodeElement(modelElement)) { - result.elements.push(modelElement); - result.details[modelElement.data.id] = modelDetails; - } - if (isAnalyticsMapEdgeElement(edgeElement)) { - result.elements.push(edgeElement); + // If rootIndex node has not been created, create it + const rootIndexDetails = await this.getIndexData(rootIndex); + result.elements.push({ + data: { + id: rootIndexNodeId, + label: rootIndex, + type: JOB_MAP_NODE_TYPES.INDEX, + }, + }); + result.details[rootIndexNodeId] = rootIndexDetails; + + // Connect incoming job to rootIndex + result.elements.push({ + data: { + id: `${currentJobNodeId}~${rootIndexNodeId}`, + source: currentJobNodeId, + target: rootIndexNodeId, + }, + }); + } else { + rootIndex = index; + rootIndexNodeId = `${rootIndex}-${JOB_MAP_NODE_TYPES.INDEX}`; } - // If destIndex node has not been created, create it - const destIndexDetails = await this.getIndexData(destIndex); - result.elements.push({ - data: { - id: destIndexNodeId, - label: destIndex, - type: JOB_MAP_NODE_TYPES.INDEX, - }, - }); - result.details[destIndexNodeId] = destIndexDetails; - - // Connect incoming job to destIndex - result.elements.push({ - data: { - id: `${currentJobNodeId}~${destIndexNodeId}`, - source: currentJobNodeId, - target: destIndexNodeId, - }, - }); - for (let i = 0; i < jobs.length; i++) { if ( - jobs[i]?.source?.index[0] === destIndex && + jobs[i]?.source?.index[0] === rootIndex && this.isDuplicateElement(jobs[i].id, result.elements) === false ) { // Create node for associated job @@ -478,8 +600,8 @@ export class AnalyticsManager { result.elements.push({ data: { - id: `${destIndexNodeId}~${nodeId}`, - source: destIndexNodeId, + id: `${rootIndexNodeId}~${nodeId}`, + source: rootIndexNodeId, target: nodeId, }, }); diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts index 5d6cec8cdfa61a..e34d68ec7840c7 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts @@ -4,6 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ +import { JobMapNodeTypes } from '../../../common/constants/data_frame_analytics'; + +interface AnalyticsMapArg { + analyticsId: string; +} +interface GetAnalyticsJobIdArg extends AnalyticsMapArg { + modelId?: never; +} +interface GetAnalyticsModelIdArg { + analyticsId?: never; + modelId: string; +} +interface ExtendAnalyticsJobIdArg extends AnalyticsMapArg { + index?: never; +} +interface ExtendAnalyticsIndexArg { + analyticsId?: never; + index: string; +} + +export type GetAnalyticsMapArgs = GetAnalyticsJobIdArg | GetAnalyticsModelIdArg; +export type ExtendAnalyticsMapArgs = ExtendAnalyticsJobIdArg | ExtendAnalyticsIndexArg; + export interface IndexPatternLinkReturnType { isWildcardIndexPattern: boolean; isIndexPattern: boolean; @@ -26,9 +49,27 @@ export type NextLinkReturnType = export type MapElements = AnalyticsMapNodeElement | AnalyticsMapEdgeElement; export interface AnalyticsMapReturnType { elements: MapElements[]; - details: object; // transform, job, or index details + details: Record<string, any>; // transform, job, or index details error: null | any; } + +interface BasicInitialElementsReturnType { + data: any; + details: object; + resultElements: MapElements[]; + modelElements: MapElements[]; +} + +export interface InitialElementsReturnType extends BasicInitialElementsReturnType { + nextLinkId?: string; + nextType?: JobMapNodeTypes; + previousNodeId?: string; +} +interface CompleteInitialElementsReturnType extends BasicInitialElementsReturnType { + nextLinkId: string; + nextType: JobMapNodeTypes; + previousNodeId: string; +} export interface AnalyticsMapNodeElement { data: { id: string; @@ -44,6 +85,16 @@ export interface AnalyticsMapEdgeElement { target: string; }; } +export const isCompleteInitialReturnType = (arg: any): arg is CompleteInitialElementsReturnType => { + if (typeof arg !== 'object' || arg === null) return false; + const keys = Object.keys(arg); + return ( + keys.length > 0 && + keys.includes('nextLinkId') && + keys.includes('nextType') && + keys.includes('previousNodeId') + ); +}; export const isAnalyticsMapNodeElement = (arg: any): arg is AnalyticsMapNodeElement => { if (typeof arg !== 'object' || arg === null) return false; const keys = Object.keys(arg); diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 8d6dd692cc130c..c157ae9e8200fb 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -17,6 +17,7 @@ "UpdateDataFrameAnalytics", "DeleteDataFrameAnalytics", "JobsExist", + "GetDataFrameAnalyticsIdMap", "DataVisualizer", "GetOverallStats", 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 8e00ae70684031..0abba7a429aea4 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -8,6 +8,7 @@ import { RequestHandlerContext, IScopedClusterClient } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/analytics_audit_messages'; import { RouteInitialization } from '../types'; +import { JOB_MAP_NODE_TYPES } from '../../common/constants/data_frame_analytics'; import { dataAnalyticsJobConfigSchema, dataAnalyticsJobUpdateSchema, @@ -19,6 +20,7 @@ import { deleteDataFrameAnalyticsJobSchema, jobsExistSchema, } from './schemas/data_analytics_schema'; +import { GetAnalyticsMapArgs, ExtendAnalyticsMapArgs } from '../models/data_frame_analytics/types'; import { IndexPatternHandler } from '../models/data_frame_analytics/index_patterns'; import { AnalyticsManager } from '../models/data_frame_analytics/analytics_manager'; import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics'; @@ -36,14 +38,22 @@ function deleteDestIndexPatternById(context: RequestHandlerContext, indexPattern return iph.deleteIndexPatternById(indexPatternId); } -function getAnalyticsMap(mlClient: MlClient, client: IScopedClusterClient, analyticsId: string) { +function getAnalyticsMap( + mlClient: MlClient, + client: IScopedClusterClient, + idOptions: GetAnalyticsMapArgs +) { const analytics = new AnalyticsManager(mlClient, client.asInternalUser); - return analytics.getAnalyticsMap(analyticsId); + return analytics.getAnalyticsMap(idOptions); } -function getExtendedMap(mlClient: MlClient, client: IScopedClusterClient, analyticsId: string) { +function getExtendedMap( + mlClient: MlClient, + client: IScopedClusterClient, + idOptions: ExtendAnalyticsMapArgs +) { const analytics = new AnalyticsManager(mlClient, client.asInternalUser); - return analytics.extendAnalyticsMapForAnalyticsJob(analyticsId); + return analytics.extendAnalyticsMapForAnalyticsJob(idOptions); } /** @@ -633,10 +643,20 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout try { const { analyticsId } = request.params; const treatAsRoot = request.query?.treatAsRoot; - const caller = - treatAsRoot === 'true' || treatAsRoot === true ? getExtendedMap : getAnalyticsMap; + const type = request.query?.type; - const results = await caller(mlClient, client, analyticsId); + let results; + if (treatAsRoot === 'true' || treatAsRoot === true) { + results = await getExtendedMap(mlClient, client, { + analyticsId: type !== JOB_MAP_NODE_TYPES.INDEX ? analyticsId : undefined, + index: type === JOB_MAP_NODE_TYPES.INDEX ? analyticsId : undefined, + }); + } else { + results = await getAnalyticsMap(mlClient, client, { + analyticsId: type !== JOB_MAP_NODE_TYPES.TRAINED_MODEL ? analyticsId : undefined, + modelId: type === JOB_MAP_NODE_TYPES.TRAINED_MODEL ? analyticsId : undefined, + }); + } return response.ok({ body: results, diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index d8226b70eb2c3f..cf52d1cb27433e 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -89,5 +89,5 @@ export const jobsExistSchema = schema.object({ }); export const analyticsMapQuerySchema = schema.maybe( - schema.object({ treatAsRoot: schema.maybe(schema.any()) }) + schema.object({ treatAsRoot: schema.maybe(schema.any()), type: schema.maybe(schema.string()) }) ); diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index dfe7280b717a3a..2c08354c9111f2 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -11,9 +11,25 @@ import { ObservabilityPluginSetupDeps } from '../plugin'; import { renderApp } from './'; describe('renderApp', () => { + const originalConsole = global.console; + beforeAll(() => { + // mocks console to avoid poluting the test output + global.console = ({ error: jest.fn() } as unknown) as typeof console; + }); + + afterAll(() => { + global.console = originalConsole; + }); it('renders', async () => { const plugins = ({ usageCollection: { reportUiStats: () => {} }, + data: { + query: { + timefilter: { + timefilter: { setTime: jest.fn(), getTime: jest.fn().mockImplementation(() => ({})) }, + }, + }, + }, } as unknown) as ObservabilityPluginSetupDeps; const core = ({ application: { currentAppId$: new Observable(), navigateToUrl: () => {} }, diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 585a45cf5279c6..ea84a417c20eb1 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -17,6 +17,7 @@ import { PluginContext } from '../context/plugin_context'; import { usePluginContext } from '../hooks/use_plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; import { ObservabilityPluginSetupDeps } from '../plugin'; +import { HasDataContextProvider } from '../context/has_data_context'; import { Breadcrumbs, routes } from '../routes'; const observabilityLabelBreadcrumb = { @@ -46,8 +47,8 @@ function App() { core.chrome.docTitle.change(getTitleFromBreadCrumbs(breadcrumb)); }, [core, breadcrumb]); - const { query, path: pathParams } = useRouteParams(route.params); - return route.handler({ query, path: pathParams }); + const params = useRouteParams(path); + return route.handler(params); }; return <Route key={path} path={path} exact={true} component={Wrapper} />; })} @@ -79,7 +80,9 @@ export const renderApp = ( <EuiThemeProvider darkMode={isDarkMode}> <i18nCore.Context> <RedirectAppLinks application={core.application}> - <App /> + <HasDataContextProvider> + <App /> + </HasDataContextProvider> </RedirectAppLinks> </i18nCore.Context> </EuiThemeProvider> diff --git a/x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx similarity index 96% rename from x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx rename to x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx index 6a05749df6d7a1..22867dde83a00d 100644 --- a/x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ISection } from '../../../typings/section'; import { render } from '../../../utils/test_helper'; -import { EmptySection } from './'; +import { EmptySection } from './empty_section'; describe('EmptySection', () => { it('renders without action button', () => { diff --git a/x-pack/plugins/observability/public/components/app/empty_section/index.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.tsx similarity index 100% rename from x-pack/plugins/observability/public/components/app/empty_section/index.tsx rename to x-pack/plugins/observability/public/components/app/empty_sections/empty_section.tsx diff --git a/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx new file mode 100644 index 00000000000000..34522ef95e27bd --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/empty_sections/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiFlexGrid, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useContext } from 'react'; +import { ThemeContext } from 'styled-components'; +import { Alert } from '../../../../../alerts/common'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { useHasData } from '../../../hooks/use_has_data'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; +import { getEmptySections } from '../../../pages/overview/empty_section'; +import { UXHasDataResponse } from '../../../typings'; +import { EmptySection } from './empty_section'; + +export function EmptySections() { + const { core } = usePluginContext(); + const theme = useContext(ThemeContext); + const { hasData } = useHasData(); + + const appEmptySections = getEmptySections({ core }).filter(({ id }) => { + if (id === 'alert') { + const { status, hasData: alerts } = hasData.alert || {}; + return ( + status === FETCH_STATUS.FAILURE || + (status === FETCH_STATUS.SUCCESS && (alerts as Alert[]).length === 0) + ); + } else { + const app = hasData[id]; + if (app) { + const _hasData = id === 'ux' ? (app.hasData as UXHasDataResponse)?.hasData : app.hasData; + return app.status === FETCH_STATUS.FAILURE || !_hasData; + } + } + return false; + }); + return ( + <EuiFlexItem> + <EuiSpacer size="s" /> + <EuiFlexGrid + columns={ + // when more than 2 empty sections are available show them on 2 columns, otherwise 1 + appEmptySections.length > 2 ? 2 : 1 + } + gutterSize="s" + > + {appEmptySections.map((app) => { + return ( + <EuiFlexItem + key={app.id} + style={{ + border: `1px dashed ${theme.eui.euiBorderColor}`, + borderRadius: '4px', + }} + > + <EmptySection section={app} /> + </EuiFlexItem> + ); + })} + </EuiFlexGrid> + </EuiFlexItem> + ); +} 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 7b9d7276dd1c56..9fdc59d61257ec 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 @@ -8,25 +8,59 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; -import moment from 'moment'; +import * as hasDataHook from '../../../../hooks/use_has_data'; +import * as pluginContext from '../../../../hooks/use_plugin_context'; +import { HasDataContextValue } from '../../../../context/has_data_context'; +import { AppMountParameters, CoreStart } from 'kibana/public'; +import { ObservabilityPluginSetupDeps } from '../../../../plugin'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), + useHistory: jest.fn(), +})); describe('APMSection', () => { + beforeAll(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasData: { + apm: { + status: fetcherHook.FETCH_STATUS.SUCCESS, + hasData: true, + }, + }, + } as HasDataContextValue); + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: ({ + uiSettings: { get: jest.fn() }, + http: { basePath: { prepend: jest.fn() } }, + } as unknown) as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); it('renders with transaction series and stats', () => { jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ data: response, status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, queryAllByTestId } = render( - <APMSection - absoluteTime={{ - start: moment('2020-06-29T11:38:23.747Z').valueOf(), - end: moment('2020-06-29T12:08:23.748Z').valueOf(), - }} - relativeTime={{ start: 'now-15m', end: 'now' }} - bucketSize="60s" - /> - ); + const { getByText, queryAllByTestId } = render(<APMSection bucketSize="60s" />); expect(getByText('APM')).toBeInTheDocument(); expect(getByText('View in app')).toBeInTheDocument(); @@ -40,16 +74,7 @@ describe('APMSection', () => { status: fetcherHook.FETCH_STATUS.LOADING, refetch: jest.fn(), }); - const { getByText, queryAllByText, getByTestId } = render( - <APMSection - absoluteTime={{ - start: moment('2020-06-29T11:38:23.747Z').valueOf(), - end: moment('2020-06-29T12:08:23.748Z').valueOf(), - }} - relativeTime={{ start: 'now-15m', end: 'now' }} - bucketSize="60s" - /> - ); + const { getByText, queryAllByText, getByTestId } = render(<APMSection bucketSize="60s" />); expect(getByText('APM')).toBeInTheDocument(); expect(getByTestId('loading')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index b635c2c68b9262..91d20d3478960c 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -12,17 +12,17 @@ import moment from 'moment'; import React, { useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { ThemeContext } from 'styled-components'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,25 +30,36 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export function APMSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function APMSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const chartTheme = useChartTheme(); const history = useHistory(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('apm')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.apm?.hasData) { + return null; + } const { appLink, stats, series } = data || {}; - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -93,7 +104,7 @@ export function APMSection({ absoluteTime, relativeTime, bucketSize }: Props) { <ChartContainer isInitialLoad={isLoading && !data}> <Settings onBrushEnd={({ x }) => onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend={false} xDomain={{ min, max }} /> diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index 343611294bc451..f60cab86453d14 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -5,19 +5,19 @@ */ import { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind, EuiSpacer, EuiTitle } from '@elastic/eui'; import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import moment from 'moment'; import React, { Fragment } from 'react'; import { useHistory } from 'react-router-dom'; -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer } from '@elastic/eui'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { LogsFetchDataResponse } from '../../../../typings'; import { formatStatValue } from '../../../../utils/format_stat_value'; import { ChartContainer } from '../../chart_container'; @@ -25,8 +25,6 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,22 +43,33 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export function LogsSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function LogsSection({ bucketSize }: Props) { const history = useHistory(); + const chartTheme = useChartTheme(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.infra_logs?.hasData) { + return null; + } - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -115,7 +124,7 @@ export function LogsSection({ absoluteTime, relativeTime, bucketSize }: Props) { <ChartContainer isInitialLoad={isLoading && !data}> <Settings onBrushEnd={({ x }) => onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend legendPosition={Position.Right} xDomain={{ min, max }} diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 8bce8205902fa5..f7fe3f5694a4a5 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -13,13 +13,13 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { Series } from '../../../../typings'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,19 +46,29 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export function MetricsSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function MetricsSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.infra_metrics?.hasData) { + return null; + } const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 879d745ff2b649..b0710a5c695a7d 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -24,34 +24,45 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { Series } from '../../../../typings'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } -export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function UptimeSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const chartTheme = useChartTheme(); const history = useHistory(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('uptime')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.uptime?.hasData) { + return null; + } - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -112,7 +123,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) <ChartContainer isInitialLoad={isLoading && !data}> <Settings onBrushEnd={({ x }) => onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend={false} legendPosition={Position.Right} xDomain={{ min, max }} 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 ef1820eaaeb3ec..be6df551663873 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 @@ -3,31 +3,63 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; -import moment from 'moment'; +import { HasDataContextValue } from '../../../../context/has_data_context'; import * as fetcherHook from '../../../../hooks/use_fetcher'; +import * as hasDataHook from '../../../../hooks/use_has_data'; +import * as pluginContext from '../../../../hooks/use_plugin_context'; +import { ObservabilityPluginSetupDeps } from '../../../../plugin'; import { render } from '../../../../utils/test_helper'; import { UXSection } from './'; import { response } from './mock_data/ux.mock'; +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), +})); + describe('UXSection', () => { + beforeAll(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasData: { + ux: { + status: fetcherHook.FETCH_STATUS.SUCCESS, + hasData: { hasData: true, serviceName: 'elastic-co-frontend' }, + }, + }, + } as HasDataContextValue); + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: ({ + uiSettings: { get: jest.fn() }, + http: { basePath: { prepend: jest.fn() } }, + } as unknown) as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); it('renders with core web vitals', () => { jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ data: response, status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, getAllByText } = render( - <UXSection - absoluteTime={{ - start: moment('2020-06-29T11:38:23.747Z').valueOf(), - end: moment('2020-06-29T12:08:23.748Z').valueOf(), - }} - relativeTime={{ start: 'now-15m', end: 'now' }} - bucketSize="60s" - serviceName="elastic-co-frontend" - /> - ); + const { getByText, getAllByText } = render(<UXSection bucketSize="60s" />); expect(getByText('User Experience')).toBeInTheDocument(); expect(getByText('View in app')).toBeInTheDocument(); @@ -59,17 +91,7 @@ describe('UXSection', () => { status: fetcherHook.FETCH_STATUS.LOADING, refetch: jest.fn(), }); - const { getByText, queryAllByText, getAllByText } = render( - <UXSection - absoluteTime={{ - start: moment('2020-06-29T11:38:23.747Z').valueOf(), - end: moment('2020-06-29T12:08:23.748Z').valueOf(), - }} - relativeTime={{ start: 'now-15m', end: 'now' }} - bucketSize="60s" - serviceName="elastic-co-frontend" - /> - ); + const { getByText, queryAllByText, getAllByText } = render(<UXSection bucketSize="60s" />); expect(getByText('User Experience')).toBeInTheDocument(); expect(getAllByText('--')).toHaveLength(3); @@ -82,17 +104,7 @@ describe('UXSection', () => { status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, queryAllByText, getAllByText } = render( - <UXSection - absoluteTime={{ - start: moment('2020-06-29T11:38:23.747Z').valueOf(), - end: moment('2020-06-29T12:08:23.748Z').valueOf(), - }} - relativeTime={{ start: 'now-15m', end: 'now' }} - bucketSize="60s" - serviceName="elastic-co-frontend" - /> - ); + const { getByText, queryAllByText, getAllByText } = render(<UXSection bucketSize="60s" />); expect(getByText('User Experience')).toBeInTheDocument(); expect(getAllByText('No data is available.')).toHaveLength(3); diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx index 0c40ce0bf7a2ef..43f1072d06fc2d 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx @@ -9,28 +9,40 @@ import React from 'react'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { UXHasDataResponse } from '../../../../typings'; import { CoreVitals } from '../../../shared/core_web_vitals'; interface Props { - serviceName: string; bucketSize: string; - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; } -export function UXSection({ serviceName, bucketSize, absoluteTime, relativeTime }: Props) { - const { start, end } = absoluteTime; - - const { data, status } = useFetcher(() => { - if (start && end) { - return getDataHandler('ux')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - serviceName, - bucketSize, - }); - } - }, [start, end, relativeTime, serviceName, bucketSize]); +export function UXSection({ bucketSize }: Props) { + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const uxHasDataResponse = (hasData.ux?.hasData as UXHasDataResponse) || {}; + const serviceName = uxHasDataResponse.serviceName as string; + + const { data, status } = useFetcher( + () => { + if (serviceName && bucketSize) { + return getDataHandler('ux')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + serviceName, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate, serviceName] + ); + + if (!uxHasDataResponse?.hasData) { + return null; + } const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx index 55746ff6576a97..4819a0760d88aa 100644 --- a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx @@ -12,11 +12,11 @@ import { EuiHorizontalRule, EuiListGroupItem, EuiPopoverProps, + EuiListGroupItemProps, } from '@elastic/eui'; - import React, { HTMLAttributes, ReactNode } from 'react'; -import { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item'; import styled from 'styled-components'; +import { EuiListGroupProps } from '@elastic/eui'; type Props = EuiPopoverProps & HTMLAttributes<HTMLDivElement>; @@ -42,9 +42,9 @@ export function SectionSubtitle({ children }: { children?: ReactNode }) { ); } -export function SectionLinks({ children }: { children?: ReactNode }) { +export function SectionLinks({ children, ...props }: { children?: ReactNode } & EuiListGroupProps) { return ( - <EuiListGroup flush={true} bordered={false}> + <EuiListGroup {...props} flush={true} bordered={false}> {children} </EuiListGroup> ); diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx index 747ec8a441c427..32c6c6054f7752 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx @@ -7,6 +7,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { useHasData } from '../../../hooks/use_has_data'; import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings'; import { usePluginContext } from '../../../hooks/use_plugin_context'; import { fromQuery, toQuery } from '../../../utils/url'; @@ -36,6 +37,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval const location = useLocation(); const history = useHistory(); const { plugins } = usePluginContext(); + const { onRefreshTimeRange } = useHasData(); useEffect(() => { plugins.data.query.timefilter.timefilter.setTime({ @@ -81,6 +83,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval function onTimeChange({ start, end }: { start: string; end: string }) { updateUrl({ rangeFrom: start, rangeTo: end }); + onRefreshTimeRange(); } return ( diff --git a/x-pack/plugins/observability/public/context/has_data_context.test.tsx b/x-pack/plugins/observability/public/context/has_data_context.test.tsx new file mode 100644 index 00000000000000..3369765c68bd1e --- /dev/null +++ b/x-pack/plugins/observability/public/context/has_data_context.test.tsx @@ -0,0 +1,467 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// import { act, getByText } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import { registerDataHandler, unregisterDataHandler } from '../data_handler'; +import { useHasData } from '../hooks/use_has_data'; +import * as routeParams from '../hooks/use_route_params'; +import * as timeRange from '../hooks/use_time_range'; +import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data'; +import { HasDataContextProvider } from './has_data_context'; +import * as pluginContext from '../hooks/use_plugin_context'; +import { PluginContextValue } from './plugin_context'; + +const relativeStart = '2020-10-08T06:00:00.000Z'; +const relativeEnd = '2020-10-08T07:00:00.000Z'; + +function wrapper({ children }: { children: React.ReactElement }) { + return <HasDataContextProvider>{children}</HasDataContextProvider>; +} + +function unregisterAll() { + unregisterDataHandler({ appName: 'apm' }); + unregisterDataHandler({ appName: 'infra_logs' }); + unregisterDataHandler({ appName: 'infra_metrics' }); + unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); +} + +function registerApps<T extends ObservabilityFetchDataPlugins>( + apps: Array<{ appName: T; hasData: HasData<T> }> +) { + apps.forEach(({ appName, hasData }) => { + registerDataHandler({ + appName, + fetchData: () => ({} as any), + hasData, + }); + }); +} + +describe('HasDataContextProvider', () => { + beforeAll(() => { + jest.spyOn(routeParams, 'useRouteParams').mockImplementation(() => ({ + query: { + from: relativeStart, + to: relativeEnd, + }, + path: {}, + })); + jest.spyOn(timeRange, 'useTimeRange').mockImplementation(() => ({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + })); + jest.spyOn(pluginContext, 'usePluginContext').mockReturnValue({ + core: ({ http: { get: jest.fn() } } as unknown) as CoreStart, + } as PluginContextValue); + }); + + describe('when no plugin has registered', () => { + it('hasAnyData returns false and all apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toMatchObject({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + describe('when plugins have registered', () => { + describe('all apps return false', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => false }, + { appName: 'infra_logs', hasData: async () => false }, + { appName: 'infra_metrics', hasData: async () => false }, + { appName: 'uptime', hasData: async () => false }, + { appName: 'ux', hasData: async () => ({ hasData: false, serviceName: undefined }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false and all apps return false', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: false, status: 'success' }, + uptime: { hasData: false, status: 'success' }, + infra_logs: { hasData: false, status: 'success' }, + infra_metrics: { hasData: false, status: 'success' }, + ux: { hasData: { hasData: false, serviceName: undefined }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('at least one app returns true', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => true }, + { appName: 'infra_logs', hasData: async () => false }, + { appName: 'infra_metrics', hasData: async () => false }, + { appName: 'uptime', hasData: async () => false }, + { appName: 'ux', hasData: async () => ({ hasData: false, serviceName: undefined }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true apm returns true and all other apps return false', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: false, status: 'success' }, + infra_logs: { hasData: false, status: 'success' }, + infra_metrics: { hasData: false, status: 'success' }, + ux: { hasData: { hasData: false, serviceName: undefined }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('all apps return true', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => true }, + { appName: 'infra_logs', hasData: async () => true }, + { appName: 'infra_metrics', hasData: async () => true }, + { appName: 'uptime', hasData: async () => true }, + { appName: 'ux', hasData: async () => ({ hasData: true, serviceName: 'ux' }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true and all apps return true', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: true, status: 'success' }, + infra_logs: { hasData: true, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: { hasData: true, serviceName: 'ux' }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('only apm is registered', () => { + describe('when apm returns true', () => { + beforeAll(() => { + registerApps([{ appName: 'apm', hasData: async () => true }]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true, apm returns true and all other apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { + wrapper, + }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('when apm returns false', () => { + beforeAll(() => { + registerApps([{ appName: 'apm', hasData: async () => false }]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false, apm returns false and all other apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { + wrapper, + }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: false, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + }); + + describe('when an app throws an error while fetching', () => { + beforeAll(() => { + registerApps([ + { + appName: 'apm', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { appName: 'infra_logs', hasData: async () => true }, + { appName: 'infra_metrics', hasData: async () => true }, + { appName: 'uptime', hasData: async () => true }, + { appName: 'ux', hasData: async () => ({ hasData: true, serviceName: 'ux' }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true, apm is undefined and all other apps return true', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'failure' }, + uptime: { hasData: true, status: 'success' }, + infra_logs: { hasData: true, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: { hasData: true, serviceName: 'ux' }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('when all apps throw an error while fetching', () => { + beforeAll(() => { + registerApps([ + { + appName: 'apm', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'infra_logs', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'infra_metrics', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'uptime', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'ux', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false and all apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'failure' }, + uptime: { hasData: undefined, status: 'failure' }, + infra_logs: { hasData: undefined, status: 'failure' }, + infra_metrics: { hasData: undefined, status: 'failure' }, + ux: { hasData: undefined, status: 'failure' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + }); + + describe('with alerts', () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockReturnValue({ + core: ({ + http: { + get: async () => { + return { + data: [ + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + ], + }; + }, + }, + } as unknown) as CoreStart, + } as PluginContextValue); + }); + + it('returns all alerts available', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { + hasData: [ + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + ], + status: 'success', + }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx new file mode 100644 index 00000000000000..79d58056af73c5 --- /dev/null +++ b/x-pack/plugins/observability/public/context/has_data_context.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniqueId } from 'lodash'; +import React, { createContext, useEffect, useState } from 'react'; +import { Alert } from '../../../alerts/common'; +import { getDataHandler } from '../data_handler'; +import { FETCH_STATUS } from '../hooks/use_fetcher'; +import { usePluginContext } from '../hooks/use_plugin_context'; +import { useTimeRange } from '../hooks/use_time_range'; +import { getObservabilityAlerts } from '../services/get_observability_alerts'; +import { ObservabilityFetchDataPlugins, UXHasDataResponse } from '../typings/fetch_overview_data'; + +type DataContextApps = ObservabilityFetchDataPlugins | 'alert'; + +export type HasDataMap = Record< + DataContextApps, + { status: FETCH_STATUS; hasData?: boolean | UXHasDataResponse | Alert[] } +>; + +export interface HasDataContextValue { + hasData: Partial<HasDataMap>; + hasAnyData: boolean; + isAllRequestsComplete: boolean; + onRefreshTimeRange: () => void; + forceUpdate: string; +} + +export const HasDataContext = createContext({} as HasDataContextValue); + +const apps: DataContextApps[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics', 'ux', 'alert']; + +export function HasDataContextProvider({ children }: { children: React.ReactNode }) { + const { core } = usePluginContext(); + const [forceUpdate, setForceUpdate] = useState(''); + const { absoluteStart, absoluteEnd } = useTimeRange(); + + const [hasData, setHasData] = useState<HasDataContextValue['hasData']>({}); + + useEffect( + () => { + apps.forEach(async (app) => { + try { + if (app !== 'alert') { + const params = + app === 'ux' + ? { absoluteTime: { start: absoluteStart, end: absoluteEnd } } + : undefined; + + const result = await getDataHandler(app)?.hasData(params); + setHasData((prevState) => ({ + ...prevState, + [app]: { + hasData: result, + status: FETCH_STATUS.SUCCESS, + }, + })); + } + } catch (e) { + setHasData((prevState) => ({ + ...prevState, + [app]: { + hasData: undefined, + status: FETCH_STATUS.FAILURE, + }, + })); + } + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + async function fetchAlerts() { + try { + const alerts = await getObservabilityAlerts({ core }); + setHasData((prevState) => ({ + ...prevState, + alert: { + hasData: alerts, + status: FETCH_STATUS.SUCCESS, + }, + })); + } catch (e) { + setHasData((prevState) => ({ + ...prevState, + alert: { + hasData: undefined, + status: FETCH_STATUS.FAILURE, + }, + })); + } + } + + fetchAlerts(); + }, [forceUpdate, core]); + + const isAllRequestsComplete = apps.every((app) => { + const appStatus = hasData[app]?.status; + return appStatus !== undefined && appStatus !== FETCH_STATUS.LOADING; + }); + + const hasAnyData = (Object.keys(hasData) as ObservabilityFetchDataPlugins[]).some( + (app) => hasData[app]?.hasData === true + ); + + return ( + <HasDataContext.Provider + value={{ + hasData, + hasAnyData, + isAllRequestsComplete, + forceUpdate, + onRefreshTimeRange: () => { + setForceUpdate(uniqueId()); + }, + }} + children={children} + /> + ); +} diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 8fdfc2bc622cad..f555f11be22518 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -3,20 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - registerDataHandler, - getDataHandler, - unregisterDataHandler, - fetchHasData, -} from './data_handler'; +import { registerDataHandler, getDataHandler } from './data_handler'; import moment from 'moment'; -import { - ApmFetchDataResponse, - LogsFetchDataResponse, - MetricsFetchDataResponse, - UptimeFetchDataResponse, - UxFetchDataResponse, -} from './typings'; const params = { absoluteTime: { @@ -447,203 +435,4 @@ describe('registerDataHandler', () => { expect(hasData).toBeTruthy(); }); }); - describe('fetchHasData', () => { - it('returns false when an exception happens', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - it('returns true when has data and false when an exception happens', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: true, - uptime: false, - infra_logs: true, - infra_metrics: false, - ux: false, - }); - }); - it('returns true when has data', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => ({ - hasData: true, - serviceName: 'elastic-co', - }), - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: true, - uptime: true, - infra_logs: true, - infra_metrics: true, - ux: { - hasData: true, - serviceName: 'elastic-co', - }, - }); - }); - it('returns false when has no data', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => false, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - it('returns false when has data was not registered', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - }); }); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index 91043a3da0dabb..7ee7db7ede17de 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - DataHandler, - HasDataResponse, - ObservabilityFetchDataPlugins, -} from './typings/fetch_overview_data'; +import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; const dataHandlers: Partial<Record<ObservabilityFetchDataPlugins, DataHandler>> = {}; @@ -34,40 +30,3 @@ export function getDataHandler<T extends ObservabilityFetchDataPlugins>(appName: return dataHandler as DataHandler<T>; } } - -export async function fetchHasData(absoluteTime: { - start: number; - end: number; -}): Promise<Record<ObservabilityFetchDataPlugins, HasDataResponse>> { - const apps: ObservabilityFetchDataPlugins[] = [ - 'apm', - 'uptime', - 'infra_logs', - 'infra_metrics', - 'ux', - ]; - - const promises = apps.map( - async (app) => - getDataHandler(app)?.hasData(app === 'ux' ? { absoluteTime } : undefined) || false - ); - - const results = await Promise.allSettled(promises); - - const [apm, uptime, logs, metrics, ux] = results.map((result) => { - if (result.status === 'fulfilled') { - return result.value; - } - - console.error('Error while fetching has data', result.reason); - return false; - }); - - return { - apm, - uptime, - ux, - infra_logs: logs, - infra_metrics: metrics, - }; -} diff --git a/x-pack/plugins/observability/public/hooks/use_has_data.ts b/x-pack/plugins/observability/public/hooks/use_has_data.ts new file mode 100644 index 00000000000000..9c66fa8861420c --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_has_data.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; +import { HasDataContext } from '../context/has_data_context'; + +export function useHasData() { + return useContext(HasDataContext); +} diff --git a/x-pack/plugins/observability/public/hooks/use_route_params.tsx b/x-pack/plugins/observability/public/hooks/use_route_params.tsx index 1b32933eec3e64..9774d9bed4244a 100644 --- a/x-pack/plugins/observability/public/hooks/use_route_params.tsx +++ b/x-pack/plugins/observability/public/hooks/use_route_params.tsx @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { useLocation, useParams } from 'react-router-dom'; import { isLeft } from 'fp-ts/lib/Either'; import { PathReporter } from 'io-ts/lib/PathReporter'; -import { Params } from '../routes'; +import { Params, RouteParams, routes } from '../routes'; function getQueryParams(location: ReturnType<typeof useLocation>) { const urlSearchParms = new URLSearchParams(location.search); @@ -23,14 +23,15 @@ function getQueryParams(location: ReturnType<typeof useLocation>) { * It removes any aditional item which is not declared in the type. * @param params */ -export function useRouteParams(params: Params) { +export function useRouteParams<T extends keyof typeof routes>(pathName: T): RouteParams<T> { const location = useLocation(); const pathParams = useParams(); const queryParams = getQueryParams(location); + const { query, path } = routes[pathName].params as Params; const rts = { - queryRt: params.query ? t.exact(params.query) : t.strict({}), - pathRt: params.path ? t.exact(params.path) : t.strict({}), + queryRt: query ? t.exact(query) : t.strict({}), + pathRt: path ? t.exact(path) : t.strict({}), }; const queryResult = rts.queryRt.decode(queryParams); @@ -43,8 +44,8 @@ export function useRouteParams(params: Params) { console.error(PathReporter.report(pathResult)[0]); } - return { + return ({ query: isLeft(queryResult) ? {} : queryResult.right, path: isLeft(pathResult) ? {} : pathResult.right, - }; + } as unknown) as RouteParams<T>; } 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 new file mode 100644 index 00000000000000..c89d52f904a96e --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useTimeRange } from './use_time_range'; +import * as pluginContext from './use_plugin_context'; +import { AppMountParameters, CoreStart } from 'kibana/public'; +import { ObservabilityPluginSetupDeps } from '../plugin'; +import * as kibanaUISettings from './use_kibana_ui_settings'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), +})); + +describe('useTimeRange', () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: {} as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ + from: '2020-10-08T05:00:00.000Z', + to: '2020-10-08T06:00:00.000Z', + })); + }); + + describe('when range from and to are not provided', () => { + describe('when data plugin has time set', () => { + it('returns ranges and absolute times from data plugin', () => { + const relativeStart = '2020-10-08T06:00:00.000Z'; + const relativeEnd = '2020-10-08T07:00:00.000Z'; + const timeRange = useTimeRange(); + expect(timeRange).toEqual({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + }); + }); + }); + describe("when data plugin doesn't have time set", () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: {} as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: undefined, + to: undefined, + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); + it('returns ranges and absolute times from kibana default settings', () => { + const relativeStart = '2020-10-08T05:00:00.000Z'; + const relativeEnd = '2020-10-08T06:00:00.000Z'; + const timeRange = useTimeRange(); + expect(timeRange).toEqual({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.ts b/x-pack/plugins/observability/public/hooks/use_time_range.ts new file mode 100644 index 00000000000000..e8bed12aaa9bdf --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_time_range.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'query-string'; +import { useLocation } from 'react-router-dom'; +import { TimePickerTime } from '../components/shared/date_picker'; +import { getAbsoluteTime } from '../utils/date'; +import { UI_SETTINGS, useKibanaUISettings } from './use_kibana_ui_settings'; +import { usePluginContext } from './use_plugin_context'; + +const getParsedParams = (search: string) => { + return parse(search.slice(1), { sort: false }); +}; + +export function useTimeRange() { + const { plugins } = usePluginContext(); + + const timePickerTimeDefaults = useKibanaUISettings<TimePickerTime>( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + const relativeStart = (rangeFrom ?? + timePickerSharedState.from ?? + timePickerTimeDefaults.from) as string; + const relativeEnd = (rangeTo ?? timePickerSharedState.to ?? timePickerTimeDefaults.to) as string; + + return { + relativeStart, + relativeEnd, + absoluteStart: getAbsoluteTime(relativeStart)!, + absoluteEnd: getAbsoluteTime(relativeEnd, { roundUp: true })!, + }; +} diff --git a/x-pack/plugins/observability/public/pages/home/index.test.tsx b/x-pack/plugins/observability/public/pages/home/index.test.tsx new file mode 100644 index 00000000000000..2c06b7035f5156 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/home/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { HasDataContextValue } from '../../context/has_data_context'; +import * as hasData from '../../hooks/use_has_data'; +import { render } from '../../utils/test_helper'; +import { HomePage } from './'; + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +describe('Home page', () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + it('renders loading component while requests are not returned', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: false, isAllRequestsComplete: false } as HasDataContextValue) + ); + const { getByText } = render(<HomePage />); + expect(getByText('Loading Observability')).toBeInTheDocument(); + }); + it('renders landing page', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: false, isAllRequestsComplete: true } as HasDataContextValue) + ); + render(<HomePage />); + expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/landing' }); + }); + it('renders overview page', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: true, isAllRequestsComplete: false } as HasDataContextValue) + ); + render(<HomePage />); + expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/overview' }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/home/index.tsx b/x-pack/plugins/observability/public/pages/home/index.tsx index 77b812dddd327e..a2a7cad1d5620e 100644 --- a/x-pack/plugins/observability/public/pages/home/index.tsx +++ b/x-pack/plugins/observability/public/pages/home/index.tsx @@ -5,33 +5,20 @@ */ import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { fetchHasData } from '../../data_handler'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { useQueryParams } from '../../hooks/use_query_params'; +import { useHasData } from '../../hooks/use_has_data'; import { LoadingObservability } from '../overview/loading_observability'; export function HomePage() { const history = useHistory(); - - const { absStart, absEnd } = useQueryParams(); - - const { data = {} } = useFetcher( - () => fetchHasData({ start: absStart, end: absEnd }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - const values = Object.values(data); - const hasSomeData = values.length ? values.some((hasData) => hasData) : null; + const { hasAnyData, isAllRequestsComplete } = useHasData(); useEffect(() => { - if (hasSomeData === true) { + if (hasAnyData === true) { history.push({ pathname: '/overview' }); - } - if (hasSomeData === false) { + } else if (hasAnyData === false && isAllRequestsComplete === true) { history.push({ pathname: '/landing' }); } - }, [hasSomeData, history]); + }, [hasAnyData, isAllRequestsComplete, history]); return <LoadingObservability />; } diff --git a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx index 2d3142d4e5804c..f0c56eb7137e2f 100644 --- a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx +++ b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx @@ -4,76 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { APMSection } from '../../components/app/section/apm'; import { LogsSection } from '../../components/app/section/logs'; import { MetricsSection } from '../../components/app/section/metrics'; -import { APMSection } from '../../components/app/section/apm'; import { UptimeSection } from '../../components/app/section/uptime'; import { UXSection } from '../../components/app/section/ux'; -import { - HasDataResponse, - ObservabilityFetchDataPlugins, - UXHasDataResponse, -} from '../../typings/fetch_overview_data'; +import { HasDataMap } from '../../context/has_data_context'; interface Props { bucketSize: string; - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; - hasData: Record<ObservabilityFetchDataPlugins, HasDataResponse>; + hasData?: Partial<HasDataMap>; } -export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) { +export function DataSections({ bucketSize }: Props) { return ( <EuiFlexItem grow={false}> <EuiFlexGroup direction="column"> - {hasData?.infra_logs && ( - <EuiFlexItem grow={false}> - <LogsSection - bucketSize={bucketSize} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - /> - </EuiFlexItem> - )} - {hasData?.infra_metrics && ( - <EuiFlexItem grow={false}> - <MetricsSection - bucketSize={bucketSize} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - /> - </EuiFlexItem> - )} - {hasData?.apm && ( - <EuiFlexItem grow={false}> - <APMSection - bucketSize={bucketSize} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - /> - </EuiFlexItem> - )} - {hasData?.uptime && ( - <EuiFlexItem grow={false}> - <UptimeSection - bucketSize={bucketSize} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - /> - </EuiFlexItem> - )} - {(hasData.ux as UXHasDataResponse).hasData && ( - <EuiFlexItem grow={false}> - <UXSection - serviceName={(hasData.ux as UXHasDataResponse).serviceName as string} - bucketSize={bucketSize} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - /> - </EuiFlexItem> - )} + <EuiFlexItem grow={false}> + <LogsSection bucketSize={bucketSize} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <MetricsSection bucketSize={bucketSize} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <APMSection bucketSize={bucketSize} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <UptimeSection bucketSize={bucketSize} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <UXSection bucketSize={bucketSize} /> + </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> ); diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index d85bd1a624d7aa..87a836b2cb32c6 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -3,27 +3,25 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; -import { useTrackPageview, UXHasDataResponse } from '../..'; -import { EmptySection } from '../../components/app/empty_section'; +import { useTrackPageview } from '../..'; +import { Alert } from '../../../../alerts/common'; +import { EmptySections } from '../../components/app/empty_sections'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; -import { DatePicker, TimePickerTime } from '../../components/shared/date_picker'; -import { fetchHasData } from '../../data_handler'; -import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; -import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; +import { DatePicker } from '../../components/shared/date_picker'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { useHasData } from '../../hooks/use_has_data'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useTimeRange } from '../../hooks/use_time_range'; import { RouteParams } from '../../routes'; import { getNewsFeed } from '../../services/get_news_feed'; -import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { DataSections } from './data_sections'; -import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; interface Props { @@ -37,47 +35,26 @@ function calculateBucketSize({ start, end }: { start?: number; end?: number }) { } export function OverviewPage({ routeParams }: Props) { - const { core, plugins } = usePluginContext(); - - // read time from state and update the url - const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - - const timePickerDefaults = useKibanaUISettings<TimePickerTime>( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - - const relativeTime = { - start: routeParams.query.rangeFrom || timePickerSharedState.from || timePickerDefaults.from, - end: routeParams.query.rangeTo || timePickerSharedState.to || timePickerDefaults.to, - }; - - const absoluteTime = { - start: getAbsoluteTime(relativeTime.start) as number, - end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number, - }; - useTrackPageview({ app: 'observability-overview', path: 'overview' }); useTrackPageview({ app: 'observability-overview', path: 'overview', delay: 15000 }); + const { core } = usePluginContext(); + const theme = useContext(ThemeContext); - const { data: alerts = [], status: alertStatus } = useFetcher(() => { - return getObservabilityAlerts({ core }); - }, [core]); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); + const relativeTime = { start: relativeStart, end: relativeEnd }; + const absoluteTime = { start: absoluteStart, end: absoluteEnd }; - const theme = useContext(ThemeContext); + const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); - const result = useFetcher( - () => fetchHasData(absoluteTime), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - const hasData = result.data; + const { hasData, hasAnyData } = useHasData(); - if (!hasData) { + if (hasAnyData === undefined) { return <LoadingObservability />; } + const alerts = (hasData.alert?.hasData as Alert[]) || []; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; const bucketSize = calculateBucketSize({ @@ -85,18 +62,6 @@ export function OverviewPage({ routeParams }: Props) { end: absoluteTime.end, }); - const appEmptySections = getEmptySections({ core }).filter(({ id }) => { - if (id === 'alert') { - return alertStatus !== FETCH_STATUS.FAILURE && !alerts.length; - } else if (id === 'ux') { - return !(hasData[id] as UXHasDataResponse).hasData; - } - return !hasData[id]; - }); - - // Hides the data section when all 'hasData' is false or undefined - const showDataSections = Object.values(hasData).some((hasPluginData) => hasPluginData); - return ( <WithHeaderLayout headerColor={theme.eui.euiColorEmptyShade} @@ -113,42 +78,9 @@ export function OverviewPage({ routeParams }: Props) { <EuiFlexGroup> <EuiFlexItem grow={6}> {/* Data sections */} - {showDataSections && ( - <DataSections - hasData={hasData} - absoluteTime={absoluteTime} - relativeTime={relativeTime} - bucketSize={bucketSize?.intervalString!} - /> - )} - - {/* Empty sections */} - {!!appEmptySections.length && ( - <EuiFlexItem> - <EuiSpacer size="s" /> - <EuiFlexGrid - columns={ - // when more than 2 empty sections are available show them on 2 columns, otherwise 1 - appEmptySections.length > 2 ? 2 : 1 - } - gutterSize="s" - > - {appEmptySections.map((app) => { - return ( - <EuiFlexItem - key={app.id} - style={{ - border: `1px dashed ${theme.eui.euiBorderColor}`, - borderRadius: '4px', - }} - > - <EmptySection section={app} /> - </EuiFlexItem> - ); - })} - </EuiFlexGrid> - </EuiFlexItem> - )} + {hasAnyData && <DataSections bucketSize={bucketSize?.intervalString!} />} + + <EmptySections /> </EuiFlexItem> {/* Alert section */} 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 8713bb12292735..a28e34e7d4dcb2 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -10,6 +10,7 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { HasDataContextProvider } from '../../context/has_data_context'; import { PluginContext } from '../../context/plugin_context'; import { registerDataHandler, unregisterDataHandler } from '../../data_handler'; import { ObservabilityPluginSetupDeps } from '../../plugin'; @@ -52,7 +53,9 @@ const withCore = makeDecorator({ } as unknown) as ObservabilityPluginSetupDeps, }} > - <EuiThemeProvider>{storyFn(context)}</EuiThemeProvider> + <EuiThemeProvider> + <HasDataContextProvider>{storyFn(context)}</HasDataContextProvider> + </EuiThemeProvider> </PluginContext.Provider> </MemoryRouter> ); diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts index 64f5f4aab1c2bb..e3f8f877656bd2 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { getObservabilityAlerts } from './get_observability_alerts'; const basePath = { prepend: (path: string) => path }; @@ -27,10 +27,9 @@ describe('getObservabilityAlerts', () => { }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; - const alerts = await getObservabilityAlerts({ core }); - expect(alerts).toEqual([]); + expect(getObservabilityAlerts({ core })).rejects.toThrow('Boom'); }); it('Returns empty array when api return undefined', async () => { @@ -43,7 +42,7 @@ describe('getObservabilityAlerts', () => { }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([]); @@ -55,32 +54,17 @@ describe('getObservabilityAlerts', () => { get: async () => { return { data: [ - { - id: 1, - consumer: 'siem', - }, - { - id: 2, - consumer: 'kibana', - }, - { - id: 3, - consumer: 'index', - }, - { - id: 4, - consumer: 'foo', - }, - { - id: 5, - consumer: 'bar', - }, + { id: 1, consumer: 'siem' }, + { id: 2, consumer: 'kibana' }, + { id: 3, consumer: 'index' }, + { id: 4, consumer: 'foo' }, + { id: 5, consumer: 'bar' }, ], }; }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([]); }); @@ -91,36 +75,18 @@ describe('getObservabilityAlerts', () => { get: async () => { return { data: [ - { - id: 1, - consumer: 'siem', - }, - { - id: 2, - consumer: 'apm', - }, - { - id: 3, - consumer: 'uptime', - }, - { - id: 4, - consumer: 'logs', - }, - { - id: 5, - consumer: 'metrics', - }, - { - id: 6, - consumer: 'alerts', - }, + { id: 1, consumer: 'siem' }, + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + { id: 4, consumer: 'logs' }, + { id: 5, consumer: 'metrics' }, + { id: 6, consumer: 'alerts' }, ], }; }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([ diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index cff6726e47df98..b1f8f0fb1bddc7 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -4,23 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { Alert } from '../../../alerts/common'; const allowedConsumers = ['apm', 'uptime', 'logs', 'metrics', 'alerts']; -export async function getObservabilityAlerts({ core }: { core: AppMountContext['core'] }) { +export async function getObservabilityAlerts({ core }: { core: CoreStart }) { try { - const { data = [] }: { data: Alert[] } = await core.http.get('/api/alerts/_find', { - query: { - page: 1, - per_page: 20, - }, - }); + const { data = [] }: { data: Alert[] } = + (await core.http.get('/api/alerts/_find', { + query: { + page: 1, + per_page: 20, + }, + })) || {}; return data.filter(({ consumer }) => allowedConsumers.includes(consumer)); } catch (e) { console.error('Error while fetching alerts', e); - return []; + throw e; } } diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 70c1eb1859ee3e..4cac1d586f295b 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -37,13 +37,13 @@ export interface UXHasDataResponse { serviceName: string | number | undefined; } -export type HasDataResponse = UXHasDataResponse | boolean; - export type FetchData<T extends FetchDataResponse = FetchDataResponse> = ( fetchDataParams: FetchDataParams ) => Promise<T>; -export type HasData = (params?: HasDataParams) => Promise<HasDataResponse>; +export type HasData<T extends ObservabilityFetchDataPlugins> = ( + params?: HasDataParams +) => Promise<ObservabilityHasDataResponse[T]>; export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, @@ -54,7 +54,7 @@ export interface DataHandler< T extends ObservabilityFetchDataPlugins = ObservabilityFetchDataPlugins > { fetchData: FetchData<ObservabilityFetchDataResponse[T]>; - hasData: HasData; + hasData: HasData<T>; } export interface FetchDataResponse { @@ -113,3 +113,11 @@ export interface ObservabilityFetchDataResponse { uptime: UptimeFetchDataResponse; ux: UxFetchDataResponse; } + +export interface ObservabilityHasDataResponse { + apm: boolean; + infra_metrics: boolean; + infra_logs: boolean; + uptime: boolean; + ux: UXHasDataResponse; +} diff --git a/x-pack/plugins/saved_objects_tagging/README.md b/x-pack/plugins/saved_objects_tagging/README.md index 5e4281a8c4e7da..0da16746f64943 100644 --- a/x-pack/plugins/saved_objects_tagging/README.md +++ b/x-pack/plugins/saved_objects_tagging/README.md @@ -1,3 +1,53 @@ # SavedObjectsTagging -Add tagging capability to saved objects \ No newline at end of file +Add tagging capability to saved objects + +## Integrating tagging on a new object type + +In addition to use the UI api to plug the tagging feature in your application, there is a couple +things that needs to be done on the server: + +### Add read-access to the `tag` SO type to your feature's capabilities + +In order to be able to fetch the tags assigned to an object, the user must have read permission +for the `tag` saved object type. Which is why all features relying on SO tagging must update +their capabilities. + +```typescript +features.registerKibanaFeature({ + id: 'myFeature', + // ... + privileges: { + all: { + // ... + savedObject: { + all: ['some-type'], + read: ['tag'], // <-- HERE + }, + }, + read: { + // ... + savedObject: { + all: [], + read: ['some-type', 'tag'], // <-- AND HERE + }, + }, + }, +}); +``` + +### Update the SOT telemetry collector schema to add the new type + +The schema is located here: `x-pack/plugins/saved_objects_tagging/server/usage/schema.ts`. You +just need to add the name of the SO type you are adding. + +```ts +export const tagUsageCollectorSchema: MakeSchemaFrom<TaggingUsageData> = { + // ... + types: { + dashboard: perTypeSchema, + visualization: perTypeSchema, + // <-- add your type here + }, +}; +``` diff --git a/x-pack/plugins/saved_objects_tagging/kibana.json b/x-pack/plugins/saved_objects_tagging/kibana.json index 89c5e7a134339b..134e48a671f28f 100644 --- a/x-pack/plugins/saved_objects_tagging/kibana.json +++ b/x-pack/plugins/saved_objects_tagging/kibana.json @@ -6,5 +6,6 @@ "ui": true, "configPath": ["xpack", "saved_object_tagging"], "requiredPlugins": ["features", "management", "savedObjectsTaggingOss"], - "requiredBundles": ["kibanaReact"] + "requiredBundles": ["kibanaReact"], + "optionalPlugins": ["usageCollection"] } diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts index 1223b1ec203897..f0c3285667817c 100644 --- a/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts @@ -8,3 +8,8 @@ export const registerRoutesMock = jest.fn(); jest.doMock('./routes', () => ({ registerRoutes: registerRoutesMock, })); + +export const createTagUsageCollectorMock = jest.fn(); +jest.doMock('./usage', () => ({ + createTagUsageCollector: createTagUsageCollectorMock, +})); diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts index 1a3e4071f5e097..0730b29cde4a8f 100644 --- a/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts @@ -4,20 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { registerRoutesMock } from './plugin.test.mocks'; +import { registerRoutesMock, createTagUsageCollectorMock } from './plugin.test.mocks'; import { coreMock } from '../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; +import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collection/server/mocks'; import { SavedObjectTaggingPlugin } from './plugin'; import { savedObjectsTaggingFeature } from './features'; describe('SavedObjectTaggingPlugin', () => { let plugin: SavedObjectTaggingPlugin; let featuresPluginSetup: ReturnType<typeof featuresPluginMock.createSetup>; + let usageCollectionSetup: ReturnType<typeof usageCollectionPluginMock.createSetupContract>; beforeEach(() => { plugin = new SavedObjectTaggingPlugin(coreMock.createPluginInitializerContext()); featuresPluginSetup = featuresPluginMock.createSetup(); + usageCollectionSetup = usageCollectionPluginMock.createSetupContract(); + // `usageCollection` 'mocked' implementation use the real `CollectorSet` implementation + // that throws when registering things that are not collectors. + // We just want to assert that it was called here, so jest.fn is fine. + usageCollectionSetup.registerCollector = jest.fn(); + }); + + afterEach(() => { + registerRoutesMock.mockReset(); + createTagUsageCollectorMock.mockReset(); }); describe('#setup', () => { @@ -43,5 +55,18 @@ describe('SavedObjectTaggingPlugin', () => { savedObjectsTaggingFeature ); }); + + it('registers the usage collector if `usageCollection` is present', async () => { + const tagUsageCollector = Symbol('saved_objects_tagging'); + createTagUsageCollectorMock.mockReturnValue(tagUsageCollector); + + await plugin.setup(coreMock.createSetup(), { + features: featuresPluginSetup, + usageCollection: usageCollectionSetup, + }); + + expect(usageCollectionSetup.registerCollector).toHaveBeenCalledTimes(1); + expect(usageCollectionSetup.registerCollector).toHaveBeenCalledWith(tagUsageCollector); + }); }); }); diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.ts index 8347fb1f8ef20a..6eb8080793d0e9 100644 --- a/x-pack/plugins/saved_objects_tagging/server/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/server/plugin.ts @@ -4,22 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { + CoreSetup, + CoreStart, + PluginInitializerContext, + Plugin, + SharedGlobalConfig, +} from 'src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { savedObjectsTaggingFeature } from './features'; import { tagType } from './saved_objects'; import { ITagsRequestHandlerContext } from './types'; -import { registerRoutes } from './routes'; import { TagsRequestHandlerContext } from './request_handler_context'; +import { registerRoutes } from './routes'; +import { createTagUsageCollector } from './usage'; interface SetupDeps { features: FeaturesPluginSetup; + usageCollection?: UsageCollectionSetup; } export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { - constructor(context: PluginInitializerContext) {} + private readonly legacyConfig$: Observable<SharedGlobalConfig>; - public setup({ savedObjects, http }: CoreSetup, { features }: SetupDeps) { + constructor(context: PluginInitializerContext) { + this.legacyConfig$ = context.config.legacy.globalConfig$; + } + + public setup({ savedObjects, http }: CoreSetup, { features, usageCollection }: SetupDeps) { savedObjects.registerType(tagType); const router = http.createRouter(); @@ -34,6 +48,15 @@ export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> { features.registerKibanaFeature(savedObjectsTaggingFeature); + if (usageCollection) { + usageCollection.registerCollector( + createTagUsageCollector({ + usageCollection, + legacyConfig$: this.legacyConfig$, + }) + ); + } + return {}; } diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts b/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts new file mode 100644 index 00000000000000..692088e66003e9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchClient } from 'src/core/server'; +import { TaggingUsageData, ByTypeTaggingUsageData } from './types'; + +/** + * Manual type reflection of the `tagDataAggregations` resulting payload + */ +interface AggregatedTagUsageResponseBody { + aggregations: { + by_type: { + buckets: Array<{ + key: string; + doc_count: number; + nested_ref: { + tag_references: { + doc_count: number; + tag_id: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; + }; + }; + }>; + }; + }; +} + +export const fetchTagUsageData = async ({ + esClient, + kibanaIndex, +}: { + esClient: ElasticsearchClient; + kibanaIndex: string; +}): Promise<TaggingUsageData> => { + const { body } = await esClient.search<AggregatedTagUsageResponseBody>({ + index: [kibanaIndex], + ignore_unavailable: true, + filter_path: 'aggregations', + body: { + size: 0, + query: { + bool: { + must: [hasTagReferenceClause], + }, + }, + aggs: tagDataAggregations, + }, + }); + + const byTypeUsages: Record<string, ByTypeTaggingUsageData> = {}; + const allUsedTags = new Set<string>(); + let totalTaggedObjects = 0; + + const typeBuckets = body.aggregations.by_type.buckets; + typeBuckets.forEach((bucket) => { + const type = bucket.key; + const taggedDocCount = bucket.doc_count; + const usedTagIds = bucket.nested_ref.tag_references.tag_id.buckets.map( + (tagBucket) => tagBucket.key + ); + + totalTaggedObjects += taggedDocCount; + usedTagIds.forEach((tagId) => allUsedTags.add(tagId)); + + byTypeUsages[type] = { + taggedObjects: taggedDocCount, + usedTags: usedTagIds.length, + }; + }); + + return { + usedTags: allUsedTags.size, + taggedObjects: totalTaggedObjects, + types: byTypeUsages, + }; +}; + +const hasTagReferenceClause = { + nested: { + path: 'references', + query: { + bool: { + must: [ + { + term: { + 'references.type': 'tag', + }, + }, + ], + }, + }, + }, +}; + +const tagDataAggregations = { + by_type: { + terms: { + field: 'type', + }, + aggs: { + nested_ref: { + nested: { + path: 'references', + }, + aggs: { + tag_references: { + filter: { + term: { + 'references.type': 'tag', + }, + }, + aggs: { + tag_id: { + terms: { + field: 'references.id', + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/apm/scripts/shared/stamp-logger.ts b/x-pack/plugins/saved_objects_tagging/server/usage/index.ts similarity index 65% rename from x-pack/plugins/apm/scripts/shared/stamp-logger.ts rename to x-pack/plugins/saved_objects_tagging/server/usage/index.ts index 65d24bbae7008b..023295ab19aef5 100644 --- a/x-pack/plugins/apm/scripts/shared/stamp-logger.ts +++ b/x-pack/plugins/saved_objects_tagging/server/usage/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import consoleStamp from 'console-stamp'; - -export function stampLogger() { - consoleStamp(console, { pattern: '[HH:MM:ss.l]' }); -} +export { createTagUsageCollector } from './tag_usage_collector'; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts new file mode 100644 index 00000000000000..8132c60daf9647 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MakeSchemaFrom } from '../../../../../src/plugins/usage_collection/server'; +import { TaggingUsageData, ByTypeTaggingUsageData } from './types'; + +const perTypeSchema: MakeSchemaFrom<ByTypeTaggingUsageData> = { + usedTags: { type: 'integer' }, + taggedObjects: { type: 'integer' }, +}; + +export const tagUsageCollectorSchema: MakeSchemaFrom<TaggingUsageData> = { + usedTags: { type: 'integer' }, + taggedObjects: { type: 'integer' }, + + types: { + dashboard: perTypeSchema, + visualization: perTypeSchema, + map: perTypeSchema, + }, +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/tag_usage_collector.ts b/x-pack/plugins/saved_objects_tagging/server/usage/tag_usage_collector.ts new file mode 100644 index 00000000000000..a38dc46193332d --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/usage/tag_usage_collector.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { SharedGlobalConfig } from 'src/core/server'; +import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/server'; +import { TaggingUsageData } from './types'; +import { fetchTagUsageData } from './fetch_tag_usage_data'; +import { tagUsageCollectorSchema } from './schema'; + +export const createTagUsageCollector = ({ + usageCollection, + legacyConfig$, +}: { + usageCollection: UsageCollectionSetup; + legacyConfig$: Observable<SharedGlobalConfig>; +}) => { + return usageCollection.makeUsageCollector<TaggingUsageData>({ + type: 'saved_objects_tagging', + isReady: () => true, + schema: tagUsageCollectorSchema, + fetch: async ({ esClient }) => { + const { kibana } = await legacyConfig$.pipe(take(1)).toPromise(); + return fetchTagUsageData({ esClient, kibanaIndex: kibana.index }); + }, + }); +}; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/types.ts b/x-pack/plugins/saved_objects_tagging/server/usage/types.ts new file mode 100644 index 00000000000000..3f6ebb752de132 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/server/usage/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * @internal + */ +export interface TaggingUsageData { + usedTags: number; + taggedObjects: number; + types: Record<string, ByTypeTaggingUsageData>; +} + +/** + * @internal + */ +export interface ByTypeTaggingUsageData { + usedTags: number; + taggedObjects: number; +} diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a0d63c0a9dd6f3..07e6ab6c72cb94 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,3 +17,5 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; + +export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint'; diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts index fd2b1cb8d1cf7e..77edd1a4ea8ddc 100644 --- a/x-pack/plugins/security/common/login_state.ts +++ b/x-pack/plugins/security/common/login_state.ts @@ -10,6 +10,7 @@ export interface LoginSelectorProvider { type: string; name: string; usesLoginForm: boolean; + showInSelector: boolean; description?: string; hint?: string; icon?: string; diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index d253fed97f353e..6eb428adf2cd5f 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -20,6 +20,19 @@ describe('#canUserChangePassword', () => { } as AuthenticatedUser) ).toEqual(true); }); + + it(`returns false for users in the ${realm} realm if used for anonymous access`, () => { + expect( + canUserChangePassword({ + username: 'foo', + authentication_provider: { type: 'anonymous', name: 'does not matter' }, + authentication_realm: { + name: 'the realm name', + type: realm, + }, + } as AuthenticatedUser) + ).toEqual(false); + }); }); it(`returns false for all other realms`, () => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d5c8d4e474c601..c22c5fc4ef0dad 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -42,5 +42,8 @@ export interface AuthenticatedUser extends User { } export function canUserChangePassword(user: AuthenticatedUser) { - return REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type); + return ( + REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) && + user.authentication_provider.type !== 'anonymous' + ); } diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 8af75633776e89..64d456c3c6b0ad 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -133,45 +133,6 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` /> `; -exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` -<LoginForm - http={ - Object { - "addLoadingCountSource": [MockFunction], - "get": [MockFunction], - } - } - infoMessage="Your session has timed out. Please log in again." - loginAssistanceMessage="" - notifications={ - Object { - "toasts": Object { - "add": [MockFunction], - "addDanger": [MockFunction], - "addError": [MockFunction], - "addInfo": [MockFunction], - "addSuccess": [MockFunction], - "addWarning": [MockFunction], - "get$": [MockFunction], - "remove": [MockFunction], - }, - } - } - selector={ - Object { - "enabled": false, - "providers": Array [ - Object { - "name": "basic1", - "type": "basic", - "usesLoginForm": true, - }, - ], - } - } -/> -`; - exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` <LoginForm http={ @@ -180,7 +141,6 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe "get": [MockFunction], } } - infoMessage="Your session has timed out. Please log in again." loginAssistanceMessage="This is an *important* message" notifications={ Object { @@ -219,7 +179,6 @@ exports[`LoginPage enabled form state renders as expected when loginHelp is set "get": [MockFunction], } } - infoMessage="Your session has timed out. Please log in again." loginAssistanceMessage="" loginHelp="**some-help**" notifications={ diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index e6d170122751ec..2b67f204848843 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -22,23 +22,40 @@ function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { ['loginForm', true], ['loginSelector', false], ['loginHelp', false], + ['autoLoginOverlay', false], ] : mode === PageMode.Selector ? [ ['loginForm', false], ['loginSelector', true], ['loginHelp', false], + ['autoLoginOverlay', false], ] : [ ['loginForm', false], ['loginSelector', false], ['loginHelp', true], + ['autoLoginOverlay', false], ]; for (const [selector, exists] of assertions) { expect(findTestSubject(wrapper, selector).exists()).toBe(exists); } } +function expectAutoLoginOverlay(wrapper: ReactWrapper) { + // Everything should be hidden except for the overlay + for (const selector of [ + 'loginForm', + 'loginSelector', + 'loginHelp', + 'loginHelpLink', + 'loginAssistanceMessage', + ]) { + expect(findTestSubject(wrapper, selector).exists()).toBe(false); + } + expect(findTestSubject(wrapper, 'autoLoginOverlay').exists()).toBe(true); +} + describe('LoginForm', () => { beforeAll(() => { Object.defineProperty(window, 'location', { @@ -57,7 +74,9 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [ + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + ], }} /> ) @@ -74,7 +93,7 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -94,7 +113,7 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -115,7 +134,7 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -147,7 +166,7 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -180,7 +199,7 @@ describe('LoginForm', () => { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -222,7 +241,7 @@ describe('LoginForm', () => { loginHelp="**some help**" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }], }} /> ); @@ -261,14 +280,22 @@ describe('LoginForm', () => { usesLoginForm: true, hint: 'Basic hint', icon: 'logoElastic', + showInSelector: true, + }, + { + type: 'saml', + name: 'saml1', + description: 'Log in w/SAML', + usesLoginForm: false, + showInSelector: true, }, - { type: 'saml', name: 'saml1', description: 'Log in w/SAML', usesLoginForm: false }, { type: 'pki', name: 'pki1', description: 'Log in w/PKI', hint: 'PKI hint', usesLoginForm: false, + showInSelector: true, }, ], }} @@ -309,8 +336,15 @@ describe('LoginForm', () => { description: 'Login w/SAML', hint: 'SAML hint', usesLoginForm: false, + showInSelector: true, + }, + { + type: 'pki', + name: 'pki1', + icon: 'some-icon', + usesLoginForm: false, + showInSelector: true, }, - { type: 'pki', name: 'pki1', icon: 'some-icon', usesLoginForm: false }, ], }} /> @@ -352,9 +386,21 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', description: 'Login w/SAML', usesLoginForm: false }, - { type: 'pki', name: 'pki1', description: 'Login w/PKI', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { + type: 'saml', + name: 'saml1', + description: 'Login w/SAML', + usesLoginForm: false, + showInSelector: true, + }, + { + type: 'pki', + name: 'pki1', + description: 'Login w/PKI', + usesLoginForm: false, + showInSelector: true, + }, ], }} /> @@ -397,8 +443,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -445,8 +491,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -488,8 +534,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -517,8 +563,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -554,8 +600,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -591,4 +637,168 @@ describe('LoginForm', () => { expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); }); }); + + describe('auto login', () => { + it('automatically switches to the Login Form mode if provider suggested by the auth provider hint needs it', () => { + const coreStartMock = coreMock.createStart(); + const wrapper = mountWithIntl( + <LoginForm + http={coreStartMock.http} + notifications={coreStartMock.notifications} + loginHelp={'**Hey this is a login help message**'} + loginAssistanceMessage="Need assistance?" + authProviderHint="basic1" + selector={{ + enabled: true, + providers: [ + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, + ], + }} + /> + ); + + expectPageMode(wrapper, PageMode.Form); + expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?'); + expect(findTestSubject(wrapper, 'loginAssistanceMessage').text()).toEqual('Need assistance?'); + }); + + it('automatically logs in if provider suggested by the auth provider hint is displayed in the selector', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({ + location: 'https://external-idp/login?optional-arg=2#optional-hash', + }); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + <LoginForm + http={coreStartMock.http} + notifications={coreStartMock.notifications} + loginHelp={'**Hey this is a login help message**'} + loginAssistanceMessage="Need assistance?" + authProviderHint="saml1" + selector={{ + enabled: true, + providers: [ + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, + ], + }} + /> + ); + + expectAutoLoginOverlay(wrapper); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('automatically logs in if provider suggested by the auth provider hint is not displayed in the selector', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({ + location: 'https://external-idp/login?optional-arg=2#optional-hash', + }); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + <LoginForm + http={coreStartMock.http} + notifications={coreStartMock.notifications} + loginHelp={'**Hey this is a login help message**'} + loginAssistanceMessage="Need assistance?" + authProviderHint="saml1" + selector={{ + enabled: true, + providers: [ + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: false }, + ], + }} + /> + ); + + expectAutoLoginOverlay(wrapper); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('switches to the login selector if could not login with provider suggested by the auth provider hint', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const failureReason = new Error('Oh no!'); + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue(failureReason); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + <LoginForm + http={coreStartMock.http} + notifications={coreStartMock.notifications} + loginHelp={'**Hey this is a login help message**'} + loginAssistanceMessage="Need assistance?" + authProviderHint="saml1" + selector={{ + enabled: true, + providers: [ + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, + ], + }} + /> + ); + + expectAutoLoginOverlay(wrapper); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe(currentURL); + expect(coreStartMock.notifications.toasts.addError).toHaveBeenCalledWith(failureReason, { + title: 'Could not perform login.', + toastMessage: 'Oh no!', + }); + + expectPageMode(wrapper, PageMode.Selector); + expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?'); + expect(findTestSubject(wrapper, 'loginAssistanceMessage').text()).toEqual('Need assistance?'); + }); + }); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index 901d43adb659d5..e37d0024852d74 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -29,7 +29,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; -import { LoginSelector } from '../../../../../common/login_state'; +import type { LoginSelector, LoginSelectorProvider } from '../../../../../common/login_state'; import { LoginValidator } from './validate_login'; interface Props { @@ -39,12 +39,12 @@ interface Props { infoMessage?: string; loginAssistanceMessage: string; loginHelp?: string; + authProviderHint?: string; } interface State { loadingState: - | { type: LoadingStateType.None } - | { type: LoadingStateType.Form } + | { type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin } | { type: LoadingStateType.Selector; providerName: string }; username: string; password: string; @@ -59,6 +59,7 @@ enum LoadingStateType { None, Form, Selector, + AutoLogin, } enum MessageType { @@ -76,11 +77,26 @@ export enum PageMode { export class LoginForm extends Component<Props, State> { private readonly validator: LoginValidator; + /** + * Optional provider that was suggested by the `auth_provider_hint={providerName}` query string parameter. If provider + * doesn't require Kibana native login form then login process is triggered automatically, otherwise Login Selector + * just switches to the Login Form mode. + */ + private readonly suggestedProvider?: LoginSelectorProvider; + constructor(props: Props) { super(props); this.validator = new LoginValidator({ shouldValidate: false }); - const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form; + this.suggestedProvider = this.props.authProviderHint + ? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint) + : undefined; + + // Switch to the Form mode right away if provider from the hint requires it. + const mode = + this.showLoginSelector() && !this.suggestedProvider?.usesLoginForm + ? PageMode.Selector + : PageMode.Form; this.state = { loadingState: { type: LoadingStateType.None }, @@ -94,7 +110,17 @@ export class LoginForm extends Component<Props, State> { }; } + async componentDidMount() { + if (this.suggestedProvider?.usesLoginForm === false) { + await this.loginWithSelector({ provider: this.suggestedProvider, autoLogin: true }); + } + } + public render() { + if (this.isLoadingState(LoadingStateType.AutoLogin)) { + return this.renderAutoLoginOverlay(); + } + return ( <Fragment> {this.renderLoginAssistanceMessage()} @@ -111,7 +137,7 @@ export class LoginForm extends Component<Props, State> { } return ( - <div className="secLoginAssistanceMessage"> + <div data-test-subj="loginAssistanceMessage" className="secLoginAssistanceMessage"> <EuiHorizontalRule size="half" /> <EuiText size="xs"> <ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown> @@ -257,9 +283,10 @@ export class LoginForm extends Component<Props, State> { }; private renderSelector = () => { + const providers = this.props.selector.providers.filter((provider) => provider.showInSelector); return ( <EuiPanel data-test-subj="loginSelector" paddingSize="none"> - {this.props.selector.providers.map((provider) => ( + {providers.map((provider) => ( <button key={provider.name} data-test-subj={`loginCard-${provider.type}/${provider.name}`} @@ -267,7 +294,7 @@ export class LoginForm extends Component<Props, State> { onClick={() => provider.usesLoginForm ? this.onPageModeChange(PageMode.Form) - : this.loginWithSelector(provider.type, provider.name) + : this.loginWithSelector({ provider }) } className={`secLoginCard ${ this.isLoadingState(LoadingStateType.Selector, provider.name) @@ -360,6 +387,30 @@ export class LoginForm extends Component<Props, State> { return null; }; + private renderAutoLoginOverlay = () => { + return ( + <EuiFlexGroup + data-test-subj="autoLoginOverlay" + alignItems="center" + justifyContent="center" + gutterSize="m" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiLoadingSpinner size="l" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText size="m" className="eui-textCenter"> + <FormattedMessage + id="xpack.security.loginPage.autoLoginAuthenticatingLabel" + defaultMessage="Authenticating…" + /> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ); + }; + private setUsernameInputRef(ref: HTMLInputElement) { if (ref) { ref.focus(); @@ -438,9 +489,17 @@ export class LoginForm extends Component<Props, State> { } }; - private loginWithSelector = async (providerType: string, providerName: string) => { + private loginWithSelector = async ({ + provider: { type: providerType, name: providerName }, + autoLogin, + }: { + provider: LoginSelectorProvider; + autoLogin?: boolean; + }) => { this.setState({ - loadingState: { type: LoadingStateType.Selector, providerName }, + loadingState: autoLogin + ? { type: LoadingStateType.AutoLogin } + : { type: LoadingStateType.Selector, providerName }, message: { type: MessageType.None }, }); @@ -466,7 +525,9 @@ export class LoginForm extends Component<Props, State> { } }; - private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean; + private isLoadingState( + type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin + ): boolean; private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean; private isLoadingState(type: LoadingStateType, providerName?: string) { const { loadingState } = this.state; @@ -482,7 +543,9 @@ export class LoginForm extends Component<Props, State> { private showLoginSelector() { return ( this.props.selector.enabled && - this.props.selector.providers.some((provider) => !provider.usesLoginForm) + this.props.selector.providers.some( + (provider) => !provider.usesLoginForm && provider.showInSelector + ) ); } } diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index 467b2a7ff99062..7110c8e130ac17 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { act } from '@testing-library/react'; import { nextTick } from '@kbn/test/jest'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import { LoginState } from '../../../common/login_state'; import { LoginPage } from './login_page'; import { coreMock } from '../../../../../../src/core/public/mocks'; @@ -37,14 +38,12 @@ describe('LoginPage', () => { httpMock.addLoadingCountSource.mockReset(); }; - beforeAll(() => { + beforeEach(() => { Object.defineProperty(window, 'location', { value: { href: 'http://some-host/bar', protocol: 'http' }, writable: true, }); - }); - beforeEach(() => { resetHttpMock(); }); @@ -206,10 +205,10 @@ describe('LoginPage', () => { expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); - it('renders as expected when info message is set', async () => { + it('properly passes query string parameters to the form', async () => { const coreStartMock = coreMock.createStart(); httpMock.get.mockResolvedValue(createLoginState()); - window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED'; + window.location.href = `http://some-host/bar?msg=SESSION_EXPIRED&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=basic1`; const wrapper = shallow( <LoginPage @@ -226,7 +225,9 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(LoginForm)).toMatchSnapshot(); + const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props(); + expect(authProviderHint).toBe('basic1'); + expect(infoMessage).toBe('Your session has timed out. Please log in again.'); }); it('renders as expected when loginAssistanceMessage is set', async () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index be152b21e27015..06469626842848 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -15,6 +15,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle } from '@elasti import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import { LoginState } from '../../../common/login_state'; import { LoginForm, DisabledLoginForm } from './components'; @@ -212,14 +213,16 @@ export class LoginPage extends Component<Props, State> { ); } + const query = parse(window.location.href, true).query; return ( <LoginForm http={this.props.http} notifications={this.props.notifications} selector={selector} - infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())} + infoMessage={infoMessageMap.get(query.msg?.toString())} loginAssistanceMessage={this.props.loginAssistanceMessage} loginHelp={loginHelp} + authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()} /> ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index e65310ba399ead..5479bc36d1ed59 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -83,18 +83,18 @@ describe('roleMappingsManagementApp', () => { }); it('mount() works for the `edit role mapping` page', async () => { - const roleMappingName = 'someRoleMappingName'; + const roleMappingName = 'role@mapping'; const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${roleMappingName}`); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Role Mappings' }, - { href: `/edit/${roleMappingName}`, text: roleMappingName }, + { href: `/edit/${encodeURIComponent(roleMappingName)}`, text: roleMappingName }, ]); expect(container).toMatchInlineSnapshot(` <div> - Role Mapping Edit Page: {"name":"someRoleMappingName","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleMappingName","search":"","hash":""}}} + Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}} </div> `); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index bca3a070e64f97..ce4ded5a9acbcf 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -12,6 +12,7 @@ import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { DocumentationLinksService } from './documentation_links'; +import { tryDecodeURIComponent } from '../url_utils'; interface CreateParams { getStartServices: StartServicesAccessor<PluginStartDependencies>; @@ -70,10 +71,14 @@ export const roleMappingsManagementApp = Object.freeze({ const EditRoleMappingsPageWithBreadcrumbs = () => { const { name } = useParams<{ name?: string }>(); + // Additional decoding is a workaround for a bug in react-router's version of the `history` module. + // See https://github.com/elastic/kibana/issues/82440 + const decodedName = name ? tryDecodeURIComponent(name) : undefined; + setBreadcrumbs([ ...roleMappingsBreadcrumbs, name - ? { text: name, href: `/edit/${encodeURIComponent(name)}` } + ? { text: decodedName, href: `/edit/${encodeURIComponent(name)}` } : { text: i18n.translate('xpack.security.roleMappings.createBreadcrumb', { defaultMessage: 'Create', @@ -83,7 +88,7 @@ export const roleMappingsManagementApp = Object.freeze({ return ( <EditRoleMappingPage - name={name} + name={decodedName} roleMappingsAPI={roleMappingsAPIClient} rolesAPIClient={new RolesAPIClient(http)} notifications={notifications} diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index c45528399db99f..8bcf58428c08d6 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -97,18 +97,18 @@ describe('rolesManagementApp', () => { }); it('mount() works for the `edit role` page', async () => { - const roleName = 'someRoleName'; + const roleName = 'role@name'; const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${roleName}`); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Roles' }, - { href: `/edit/${roleName}`, text: roleName }, + { href: `/edit/${encodeURIComponent(roleName)}`, text: roleName }, ]); expect(container).toMatchInlineSnapshot(` <div> - Role Edit Page: {"action":"edit","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleName","search":"","hash":""}}} + Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}} </div> `); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index 88aeb1d232fc7d..d5b3b4998a09d9 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -13,6 +13,7 @@ import { RegisterManagementAppArgs } from '../../../../../../src/plugins/managem import { SecurityLicense } from '../../../common/licensing'; import { PluginStartDependencies } from '../../plugin'; import { DocumentationLinksService } from './documentation_links'; +import { tryDecodeURIComponent } from '../url_utils'; interface CreateParams { fatalErrors: FatalErrorsSetup; @@ -68,10 +69,14 @@ export const rolesManagementApp = Object.freeze({ const EditRolePageWithBreadcrumbs = ({ action }: { action: 'edit' | 'clone' }) => { const { roleName } = useParams<{ roleName?: string }>(); + // Additional decoding is a workaround for a bug in react-router's version of the `history` module. + // See https://github.com/elastic/kibana/issues/82440 + const decodedRoleName = roleName ? tryDecodeURIComponent(roleName) : undefined; + setBreadcrumbs([ ...rolesBreadcrumbs, action === 'edit' && roleName - ? { text: roleName, href: `/edit/${encodeURIComponent(roleName)}` } + ? { text: decodedRoleName, href: `/edit/${encodeURIComponent(roleName)}` } : { text: i18n.translate('xpack.security.roles.createBreadcrumb', { defaultMessage: 'Create', @@ -82,7 +87,7 @@ export const rolesManagementApp = Object.freeze({ return ( <EditRolePage action={action} - roleName={roleName} + roleName={decodedRoleName} rolesAPIClient={rolesAPIClient} userAPIClient={new UserAPIClient(http)} indicesAPIClient={new IndicesAPIClient(http)} diff --git a/x-pack/plugins/security/public/management/uri_utils.test.ts b/x-pack/plugins/security/public/management/uri_utils.test.ts new file mode 100644 index 00000000000000..029228d911c05c --- /dev/null +++ b/x-pack/plugins/security/public/management/uri_utils.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { tryDecodeURIComponent } from './url_utils'; + +describe('tryDecodeURIComponent', () => { + it('properly decodes a URI Component', () => { + expect( + tryDecodeURIComponent('sample%26piece%3Dof%20text%40gmail.com%2520') + ).toMatchInlineSnapshot(`"sample&piece=of text@gmail.com%20"`); + }); + + it('returns the original string undecoded if it is malformed', () => { + expect(tryDecodeURIComponent('sample&piece=of%text@gmail.com%20')).toMatchInlineSnapshot( + `"sample&piece=of%text@gmail.com%20"` + ); + }); +}); diff --git a/x-pack/plugins/security/public/management/url_utils.ts b/x-pack/plugins/security/public/management/url_utils.ts new file mode 100644 index 00000000000000..590863e30d5ec4 --- /dev/null +++ b/x-pack/plugins/security/public/management/url_utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const tryDecodeURIComponent = (uriComponent: string) => { + try { + return decodeURIComponent(uriComponent); + } catch { + return uriComponent; + } +}; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 06bd2eff6aa1e5..c9e448d90d925c 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -86,18 +86,18 @@ describe('usersManagementApp', () => { }); it('mount() works for the `edit user` page', async () => { - const userName = 'someUserName'; + const userName = 'foo@bar.com'; const { setBreadcrumbs, container, unmount } = await mountApp('/', `/edit/${userName}`); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Users' }, - { href: `/edit/${userName}`, text: userName }, + { href: `/edit/${encodeURIComponent(userName)}`, text: userName }, ]); expect(container).toMatchInlineSnapshot(` <div> - User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"someUserName","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someUserName","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"foo@bar.com","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/foo@bar.com","search":"","hash":""}}} </div> `); @@ -106,18 +106,23 @@ describe('usersManagementApp', () => { expect(container).toMatchInlineSnapshot(`<div />`); }); - it('mount() properly encodes user name in `edit user` page link in breadcrumbs', async () => { - const username = 'some 安全性 user'; - - const { setBreadcrumbs } = await mountApp('/', `/edit/${username}`); - - expect(setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(setBreadcrumbs).toHaveBeenCalledWith([ - { href: `/`, text: 'Users' }, - { - href: '/edit/some%20%E5%AE%89%E5%85%A8%E6%80%A7%20user', - text: username, - }, - ]); + const usernames = ['foo@bar.com', 'foo&bar.com', 'some 安全性 user']; + usernames.forEach((username) => { + it( + 'mount() properly encodes user name in `edit user` page link in breadcrumbs for user ' + + username, + async () => { + const { setBreadcrumbs } = await mountApp('/', `/edit/${username}`); + + expect(setBreadcrumbs).toHaveBeenCalledTimes(1); + expect(setBreadcrumbs).toHaveBeenCalledWith([ + { href: `/`, text: 'Users' }, + { + href: `/edit/${encodeURIComponent(username)}`, + text: username, + }, + ]); + } + ); }); }); diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 82c55d67b9026e..2f16f85d5fcae8 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -12,6 +12,7 @@ import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { AuthenticationServiceSetup } from '../../authentication'; import { PluginStartDependencies } from '../../plugin'; +import { tryDecodeURIComponent } from '../url_utils'; interface CreateParams { authc: AuthenticationServiceSetup; @@ -66,10 +67,14 @@ export const usersManagementApp = Object.freeze({ const EditUserPageWithBreadcrumbs = () => { const { username } = useParams<{ username?: string }>(); + // Additional decoding is a workaround for a bug in react-router's version of the `history` module. + // See https://github.com/elastic/kibana/issues/82440 + const decodedUsername = username ? tryDecodeURIComponent(username) : undefined; + setBreadcrumbs([ ...usersBreadcrumbs, username - ? { text: username, href: `/edit/${encodeURIComponent(username)}` } + ? { text: decodedUsername, href: `/edit/${encodeURIComponent(username)}` } : { text: i18n.translate('xpack.security.users.createBreadcrumb', { defaultMessage: 'Create', @@ -83,7 +88,7 @@ export const usersManagementApp = Object.freeze({ userAPIClient={userAPIClient} rolesAPIClient={new RolesAPIClient(http)} notifications={notifications} - username={username} + username={decodedUsername} history={history} /> ); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 4a2b86447b7f78..66b8002788dcbb 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -8,14 +8,16 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { shallowWithIntl, nextTick, mountWithIntl } from '@kbn/test/jest'; import { SecurityNavControl } from './nav_control_component'; -import { AuthenticatedUser } from '../../common/model'; +import type { AuthenticatedUser } from '../../common/model'; import { EuiPopover, EuiHeaderSectionItemButton } from '@elastic/eui'; import { findTestSubject } from '@kbn/test/jest'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; + describe('SecurityNavControl', () => { it(`renders a loading spinner when the user promise hasn't resolved yet.`, async () => { const props = { - user: new Promise(() => {}) as Promise<AuthenticatedUser>, + user: new Promise<AuthenticatedUser>(() => mockAuthenticatedUser()), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -41,7 +43,7 @@ describe('SecurityNavControl', () => { it(`renders an avatar after the user promise resolves.`, async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -70,7 +72,7 @@ describe('SecurityNavControl', () => { it(`doesn't render the popover when the user hasn't been loaded yet`, async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -92,7 +94,7 @@ describe('SecurityNavControl', () => { it('renders a popover when the avatar is clicked.', async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -115,7 +117,7 @@ describe('SecurityNavControl', () => { it('renders a popover with additional user menu links registered by other plugins', async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([ @@ -145,4 +147,37 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); }); + + it('properly renders a popover for anonymous user.', async () => { + const props = { + user: Promise.resolve( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, + }) + ), + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, + { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, + ]), + }; + + const wrapper = mountWithIntl(<SecurityNavControl {...props} />); + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + + expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c22308fa8a43e0..e846539025452f 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -118,33 +118,23 @@ export class SecurityNavControl extends Component<Props, State> { </EuiHeaderSectionItemButton> ); - const profileMenuItem = { - name: ( - <FormattedMessage - id="xpack.security.navControlComponent.editProfileLinkText" - defaultMessage="Profile" - /> - ), - icon: <EuiIcon type="user" size="m" />, - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }; - - const logoutMenuItem = { - name: ( - <FormattedMessage - id="xpack.security.navControlComponent.logoutLinkText" - defaultMessage="Log out" - /> - ), - icon: <EuiIcon type="exit" size="m" />, - href: logoutUrl, - 'data-test-subj': 'logoutLink', - }; - + const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous'; const items: EuiContextMenuPanelItemDescriptor[] = []; - items.push(profileMenuItem); + if (!isAnonymousUser) { + const profileMenuItem = { + name: ( + <FormattedMessage + id="xpack.security.navControlComponent.editProfileLinkText" + defaultMessage="Profile" + /> + ), + icon: <EuiIcon type="user" size="m" />, + href: editProfileUrl, + 'data-test-subj': 'profileLink', + }; + items.push(profileMenuItem); + } if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks @@ -162,6 +152,22 @@ export class SecurityNavControl extends Component<Props, State> { }); } + const logoutMenuItem = { + name: isAnonymousUser ? ( + <FormattedMessage + id="xpack.security.navControlComponent.loginLinkText" + defaultMessage="Log in" + /> + ) : ( + <FormattedMessage + id="xpack.security.navControlComponent.logoutLinkText" + defaultMessage="Log out" + /> + ), + icon: <EuiIcon type="exit" size="m" />, + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }; items.push(logoutMenuItem); const panels = [ diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index eef45598d1761c..718415e4857251 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -10,6 +10,7 @@ import { ILegacyClusterClient, IBasePath, } from '../../../../../src/core/server'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constants'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticatedUser } from '../../common/model'; import type { AuthenticationProvider } from '../../common/types'; @@ -20,6 +21,7 @@ import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { SessionValue, Session } from '../session_management'; import { + AnonymousAuthenticationProvider, AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, BaseAuthenticationProvider, @@ -86,6 +88,7 @@ const providerMap = new Map< [TokenAuthenticationProvider.type, TokenAuthenticationProvider], [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], ]); /** @@ -328,19 +331,26 @@ export class Authenticator { assertRequest(request); const existingSessionValue = await this.getSessionValue(request); + const suggestedProviderName = + existingSessionValue?.provider.name ?? + request.url.searchParams.get(AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER); if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) { this.logger.debug('Redirecting request to Login Selector.'); return AuthenticationResult.redirectTo( `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}` - )}` + )}${ + suggestedProviderName && !existingSessionValue + ? `&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=${encodeURIComponent( + suggestedProviderName + )}` + : '' + }` ); } - for (const [providerName, provider] of this.providerIterator( - existingSessionValue?.provider.name - )) { + for (const [providerName, provider] of this.providerIterator(suggestedProviderName)) { // Check if current session has been set by this provider. const ownsSession = existingSessionValue?.provider.name === providerName && diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts new file mode 100644 index 00000000000000..c296cb9c8e94d5 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { mockAuthenticationProviderOptions } from './base.mock'; + +import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from '../http_authentication'; +import { AnonymousAuthenticationProvider } from './anonymous'; + +function expectAuthenticateCall( + mockClusterClient: jest.Mocked<ILegacyClusterClient>, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + +describe('AnonymousAuthenticationProvider', () => { + const user = mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'anonymous1' }, + }); + + for (const useBasicCredentials of [true, false]) { + describe(`with ${useBasicCredentials ? '`Basic`' : '`ApiKey`'} credentials`, () => { + let provider: AnonymousAuthenticationProvider; + let mockOptions: ReturnType<typeof mockAuthenticationProviderOptions>; + let authorization: string; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' }); + + provider = useBasicCredentials + ? new AnonymousAuthenticationProvider(mockOptions, { + credentials: { username: 'user', password: 'pass' }, + }) + : new AnonymousAuthenticationProvider(mockOptions, { + credentials: { apiKey: 'some-apiKey' }, + }); + authorization = useBasicCredentials + ? new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials('user', 'pass').toString() + ).toString() + : new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString(); + }); + + describe('`login` method', () => { + it('succeeds if credentials are valid, and creates session and authHeaders', async () => { + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} })) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: { authorization }, + state: {}, + }) + ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('fails if user cannot be retrieved during login attempt', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const authenticationError = new Error('Some error'); + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.login(request)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + }); + + describe('`authenticate` method', () => { + it('does not create session for AJAX requests.', async () => { + // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and + // avoid triggering of redirect logic. + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), + null + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not create session for request that do not require authentication.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + }); + + it('does not handle authentication via `authorization` header even if state exists.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + }); + + it('succeeds for non-AJAX requests if state is available.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('succeeds for AJAX requests if state is available.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization, 'kbn-xsrf': 'xsrf' }, + }); + }); + + it('non-AJAX requests can start a new session.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: {}, authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('fails if credentials are not valid.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const authenticationError = new Error('Forbidden'); + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + if (!useBasicCredentials) { + it('properly handles extended format for the ApiKey credentials', async () => { + provider = new AnonymousAuthenticationProvider(mockOptions, { + credentials: { apiKey: { id: 'some-id', key: 'some-key' } }, + }); + authorization = new HTTPAuthorizationHeader( + 'ApiKey', + new BasicHTTPAuthorizationHeaderCredentials('some-id', 'some-key').toString() + ).toString(); + + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + } + }); + + describe('`logout` method', () => { + it('does not handle logout if state is not present', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( + DeauthenticationResult.notHandled() + ); + }); + + it('always redirects to the logged out page.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); + + await expect( + provider.logout(httpServerMock.createKibanaRequest(), null) + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); + }); + }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe( + useBasicCredentials ? 'basic' : 'apikey' + ); + }); + }); + } +}); diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts new file mode 100644 index 00000000000000..6f02cce371a413 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { canRedirectRequest } from '../can_redirect_request'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from '../http_authentication'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; + +/** + * Credentials that are based on the username and password. + */ +interface UsernameAndPasswordCredentials { + username: string; + password: string; +} + +/** + * Credentials that are based on the Elasticsearch API key. + */ +interface APIKeyCredentials { + apiKey: { id: string; key: string } | string; +} + +/** + * Checks whether current request can initiate a new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and it's not XHR request. + // Technically we can authenticate XHR requests too, but we don't want these to create a new session unintentionally. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + +/** + * Checks whether specified `credentials` define an API key. + * @param credentials + */ +function isAPIKeyCredentials( + credentials: UsernameAndPasswordCredentials | APIKeyCredentials +): credentials is APIKeyCredentials { + return !!(credentials as APIKeyCredentials).apiKey; +} + +/** + * Provider that supports anonymous request authentication. + */ +export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Type of the provider. + */ + static readonly type = 'anonymous'; + + /** + * Defines HTTP authorization header that should be used to authenticate request. + */ + private readonly httpAuthorizationHeader: HTTPAuthorizationHeader; + + constructor( + protected readonly options: Readonly<AuthenticationProviderOptions>, + anonymousOptions?: Readonly<{ + credentials?: Readonly<UsernameAndPasswordCredentials | APIKeyCredentials>; + }> + ) { + super(options); + + const credentials = anonymousOptions?.credentials; + if (!credentials) { + throw new Error('Credentials must be specified'); + } + + if (isAPIKeyCredentials(credentials)) { + this.logger.debug('Anonymous requests will be authenticated via API key.'); + this.httpAuthorizationHeader = new HTTPAuthorizationHeader( + 'ApiKey', + typeof credentials.apiKey === 'string' + ? credentials.apiKey + : new BasicHTTPAuthorizationHeaderCredentials( + credentials.apiKey.id, + credentials.apiKey.key + ).toString() + ); + } else { + this.logger.debug('Anonymous requests will be authenticated via username and password.'); + this.httpAuthorizationHeader = new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + credentials.username, + credentials.password + ).toString() + ); + } + } + + /** + * Performs initial login request. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async login(request: KibanaRequest, state?: unknown) { + this.logger.debug('Trying to perform a login.'); + return this.authenticateViaAuthorizationHeader(request, state); + } + + /** + * Performs request authentication. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async authenticate(request: KibanaRequest, state?: unknown) { + this.logger.debug( + `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` + ); + + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); + } + + if (state || canStartNewSession(request)) { + return this.authenticateViaAuthorizationHeader(request, state); + } + + return AuthenticationResult.notHandled(); + } + + /** + * Redirects user to the logged out page. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: unknown) { + this.logger.debug( + `Logout is initiated by request to ${request.url.pathname}${request.url.search}.` + ); + + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { + return DeauthenticationResult.notHandled(); + } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); + } + + /** + * Returns HTTP authentication scheme (`Basic` or `ApiKey`) that's used within `Authorization` + * HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return this.httpAuthorizationHeader.scheme.toLowerCase(); + } + + /** + * Tries to authenticate user request via configured credentials encoded into `Authorization` header. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaAuthorizationHeader(request: KibanaRequest, state?: unknown) { + const authHeaders = { authorization: this.httpAuthorizationHeader.toString() }; + try { + const user = await this.getUser(request, authHeaders); + this.logger.debug( + `Request to ${request.url.pathname}${request.url.search} has been authenticated.` + ); + // Create session only if it doesn't exist yet, otherwise keep it unchanged. + return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} }); + } catch (err) { + this.logger.debug(`Failed to authenticate request : ${err.message}`); + return AuthenticationResult.failed(err); + } + } +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index 048afb6190d18c..cfa9e715050669 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -9,6 +9,7 @@ export { AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, } from './base'; +export { AnonymousAuthenticationProvider } from './anonymous'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 76a6586e5af803..a306e701e4e8d2 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -28,6 +28,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -76,6 +77,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -124,6 +126,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -863,6 +866,253 @@ describe('config schema', () => { }); }); + describe('`anonymous` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { anonymous: { anonymous1: { enabled: true } } } }, + }) + ).toThrow( + '[authc.providers.1.anonymous.anonymous1.order]: expected value of type [number] but got [undefined]' + ); + }); + + it('requires `credentials`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { anonymous: { anonymous1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: expected at least one defined value but got [undefined]" + `); + }); + + it('requires both `username` and `password` in username/password `credentials`', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { anonymous1: { order: 0, credentials: { username: 'some-user' } } }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.password]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: expected at least one defined value but got [undefined]" + `); + + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { anonymous1: { order: 0, credentials: { password: 'some-pass' } } }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.username]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: expected at least one defined value but got [undefined]" + `); + }); + + it('can be successfully validated with username/password credentials', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "password": "some-pass", + "username": "some-user", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + }); + + it('requires both `id` and `key` in extended `apiKey` format credentials', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { anonymous1: { order: 0, credentials: { apiKey: { id: 'some-id' } } } }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.username]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: types that failed validation: + - [credentials.apiKey.0.key]: expected value of type [string] but got [undefined] + - [credentials.apiKey.1]: expected value of type [string] but got [Object]" + `); + + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { order: 0, credentials: { apiKey: { key: 'some-key' } } }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.username]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: types that failed validation: + - [credentials.apiKey.0.id]: expected value of type [string] but got [undefined] + - [credentials.apiKey.1]: expected value of type [string] but got [Object]" + `); + }); + + it('can be successfully validated with API keys credentials', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { apiKey: 'some-API-key' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "apiKey": "some-API-key", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { apiKey: { id: 'some-id', key: 'some-key' } }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "apiKey": Object { + "id": "some-id", + "key": "some-key", + }, + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + }); + + it('can be successfully validated with session config overrides', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "password": "some-pass", + "username": "some-user", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 1, + "session": Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + }, + "showInSelector": true, + }, + }, + } + `); + }); + }); + it('`name` should be unique across all provider types', () => { expect(() => ConfigSchema.validate({ @@ -1623,5 +1873,113 @@ describe('createConfig()', () => { } `); }); + + it('properly handles config for the anonymous provider', async () => { + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": "P30D", + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + session: { idleTimeout: 0, lifespan: null }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": null, + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 0, lifespan: null }, + }, + }, + }, + }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": null, + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + session: { idleTimeout: null, lifespan: 0 }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + session: { idleTimeout: 123, lifespan: 456 }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + } + `); + }); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index f44c68588fd619..b46c8dc2178a40 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -51,18 +51,27 @@ function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonCon }; } -function getUniqueProviderSchema( +function getUniqueProviderSchema<TProperties extends Record<string, Type<any>>>( providerType: string, - overrides?: Partial<ProvidersCommonConfigType> + overrides?: Partial<ProvidersCommonConfigType>, + properties?: TProperties ) { return schema.maybe( - schema.recordOf(schema.string(), schema.object(getCommonProviderSchemaProperties(overrides)), { - validate(config) { - if (Object.values(config).filter((provider) => provider.enabled).length > 1) { - return `Only one "${providerType}" provider can be configured.`; - } - }, - }) + schema.recordOf( + schema.string(), + schema.object( + properties + ? { ...getCommonProviderSchemaProperties(overrides), ...properties } + : getCommonProviderSchemaProperties(overrides) + ), + { + validate(config) { + if (Object.values(config).filter((provider) => provider.enabled).length > 1) { + return `Only one "${providerType}" provider can be configured.`; + } + }, + } + ) ); } @@ -120,6 +129,40 @@ const providersConfigSchema = schema.object( schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) ) ), + anonymous: getUniqueProviderSchema( + 'anonymous', + { + description: schema.string({ + defaultValue: i18n.translate('xpack.security.loginAsGuestLabel', { + defaultMessage: 'Continue as Guest', + }), + }), + hint: schema.string({ + defaultValue: i18n.translate('xpack.security.loginAsGuestHintLabel', { + defaultMessage: 'For anonymous users', + }), + }), + icon: schema.string({ defaultValue: 'globe' }), + session: schema.object({ + idleTimeout: schema.nullable(schema.duration()), + lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])), + }), + }, + { + credentials: schema.oneOf([ + schema.object({ + username: schema.string(), + password: schema.string(), + }), + schema.object({ + apiKey: schema.oneOf([ + schema.object({ id: schema.string(), key: schema.string() }), + schema.string(), + ]), + }), + ]), + } + ), }, { validate(config) { @@ -196,6 +239,7 @@ export const ConfigSchema = schema.object({ oidc: undefined, pki: undefined, kerberos: undefined, + anonymous: undefined, }, }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), @@ -335,6 +379,7 @@ export function createConfig( } function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) { + const defaultAnonymousSessionLifespan = schema.duration().validate('30d'); return { cleanupInterval: session.cleanupInterval, getExpirationTimeouts({ type, name }: AuthenticationProvider) { @@ -343,9 +388,20 @@ function getSessionConfig(session: RawConfigType['session'], providers: Provider // provider doesn't override session config and we should fall back to the global one instead. const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session; + // We treat anonymous sessions differently since users can create them without realizing it. This may lead to a + // non controllable amount of sessions stored in the session index. To reduce the impact we set a 30 days lifespan + // for the anonymous sessions in case neither global nor provider specific lifespan is configured explicitly. + // We can remove this code once https://github.com/elastic/kibana/issues/68885 is resolved. + const providerLifespan = + type === 'anonymous' && + providerSessionConfig?.lifespan === undefined && + session.lifespan === undefined + ? defaultAnonymousSessionLifespan + : providerSessionConfig?.lifespan; + const [idleTimeout, lifespan] = [ [session.idleTimeout, providerSessionConfig?.idleTimeout], - [session.lifespan, providerSessionConfig?.lifespan], + [session.lifespan, providerLifespan], ].map(([globalTimeout, providerTimeout]) => { const timeout = providerTimeout === undefined ? globalTimeout ?? null : providerTimeout; return timeout && timeout.asMilliseconds() > 0 ? timeout : null; diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index b90a44be7aade1..11b2cdcac021b5 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -185,7 +185,7 @@ describe('Login view routes', () => { requiresSecureConnection: false, selector: { enabled: false, - providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }], + providers: [{ name: 'basic', type: 'basic', usesLoginForm: true, showInSelector: true }], }, }; await expect( @@ -209,7 +209,7 @@ describe('Login view routes', () => { requiresSecureConnection: false, selector: { enabled: false, - providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }], + providers: [{ name: 'basic', type: 'basic', usesLoginForm: true, showInSelector: true }], }, }; await expect( @@ -253,6 +253,7 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, + showInSelector: true, icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, @@ -265,6 +266,7 @@ describe('Login view routes', () => { name: 'token1', type: 'token', usesLoginForm: true, + showInSelector: true, icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, @@ -296,7 +298,7 @@ describe('Login view routes', () => { const contextMock = coreMock.createRequestHandlerContext(); const cases: Array<[ConfigType['authc'], LoginSelectorProvider[]]> = [ - // selector is disabled, multiple providers, but only basic provider should be returned. + // selector is disabled, multiple providers, all providers should be returned. [ getAuthcConfig({ selector: { enabled: false }, @@ -310,9 +312,16 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, + showInSelector: true, icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, + { + type: 'saml', + name: 'saml1', + usesLoginForm: false, + showInSelector: false, + }, ], ], // selector is enabled, but only basic/token is available and should be returned. @@ -326,12 +335,13 @@ describe('Login view routes', () => { name: 'basic1', type: 'basic', usesLoginForm: true, + showInSelector: true, icon: 'logoElasticsearch', description: 'Log in with Elasticsearch', }, ], ], - // selector is enabled, all providers should be returned + // selector is enabled [ getAuthcConfig({ selector: { enabled: true }, @@ -345,7 +355,13 @@ describe('Login view routes', () => { }, }, saml: { - saml1: { order: 1, description: 'some-desc2', realm: 'realm1', icon: 'some-icon2' }, + saml1: { + order: 1, + description: 'some-desc2', + realm: 'realm1', + icon: 'some-icon2', + showInSelector: false, + }, saml2: { order: 2, description: 'some-desc3', hint: 'some-hint3', realm: 'realm2' }, }, }, @@ -358,6 +374,7 @@ describe('Login view routes', () => { hint: 'some-hint1', icon: 'logoElasticsearch', usesLoginForm: true, + showInSelector: true, }, { type: 'saml', @@ -365,6 +382,7 @@ describe('Login view routes', () => { description: 'some-desc2', icon: 'some-icon2', usesLoginForm: false, + showInSelector: false, }, { type: 'saml', @@ -372,55 +390,7 @@ describe('Login view routes', () => { description: 'some-desc3', hint: 'some-hint3', usesLoginForm: false, - }, - ], - ], - // selector is enabled, only providers that are enabled should be returned. - [ - getAuthcConfig({ - selector: { enabled: true }, - providers: { - basic: { - basic1: { - order: 0, - description: 'some-desc1', - hint: 'some-hint1', - icon: 'some-icon1', - }, - }, - saml: { - saml1: { - order: 1, - description: 'some-desc2', - realm: 'realm1', - showInSelector: false, - }, - saml2: { - order: 2, - description: 'some-desc3', - hint: 'some-hint3', - icon: 'some-icon3', - realm: 'realm2', - }, - }, - }, - }), - [ - { - type: 'basic', - name: 'basic1', - description: 'some-desc1', - hint: 'some-hint1', - icon: 'some-icon1', - usesLoginForm: true, - }, - { - type: 'saml', - name: 'saml2', - description: 'some-desc3', - hint: 'some-hint3', - icon: 'some-icon3', - usesLoginForm: false, + showInSelector: true, }, ], ], diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index f72facb2e24cc9..93d43d04a86ca3 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -55,18 +55,21 @@ export function defineLoginRoutes({ const { allowLogin, layout = 'form' } = license.getFeatures(); const { sortedProviders, selector } = config.authc; - const providers = []; - for (const { type, name } of sortedProviders) { + const providers = sortedProviders.map(({ type, name }) => { // Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can // be sure that config is present for every provider in `config.authc.sortedProviders`. const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!; - - // Include provider into the list if either selector is enabled or provider uses login form. const usesLoginForm = type === 'basic' || type === 'token'; - if (showInSelector && (usesLoginForm || selector.enabled)) { - providers.push({ type, name, usesLoginForm, description, hint, icon }); - } - } + return { + type, + name, + usesLoginForm, + showInSelector: showInSelector && (usesLoginForm || selector.enabled), + description, + hint, + icon, + }; + }); const loginState: LoginState = { allowLogin, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts index 6be51d2a1adc23..26d2a2cff2910b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts @@ -40,9 +40,11 @@ export const getCreateSavedQueryRulesSchemaMock = (ruleId = 'rule-1'): SavedQuer }); export const getCreateThreatMatchRulesSchemaMock = ( - ruleId = 'rule-1' + ruleId = 'rule-1', + enabled = false ): ThreatMatchCreateSchema => ({ description: 'Detecting root and admin users', + enabled, name: 'Query with a rule id', query: 'user.name: root or user.name: admin', severity: 'high', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 08c544b9246e0c..1bf6b64db24273 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -115,12 +115,12 @@ export const getThreatMatchingSchemaMock = (anchorDate: string = ANCHOR_DATE): R * Useful for e2e backend tests where it doesn't have date time and other * server side properties attached to it. */ -export const getThreatMatchingSchemaPartialMock = (): Partial<RulesSchema> => { +export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial<RulesSchema> => { return { author: [], created_by: 'elastic', description: 'Detecting root and admin users', - enabled: true, + enabled, false_positives: [], from: 'now-6m', immutable: false, 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 082f5100952abd..a4bdc4fc59a7cb 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -118,21 +118,29 @@ const APPLIED_POLICIES: Array<{ name: string; id: string; status: HostPolicyResponseActionStatus; + endpoint_policy_version: number; + version: number; }> = [ { name: 'Default', id: '00000000-0000-0000-0000-000000000000', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 1, + version: 3, }, { name: 'With Eventing', id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 3, + version: 5, }, { name: 'Detect Malware Only', id: '47d7965d-6869-478b-bd9c-fb0d2bb3959f', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 4, + version: 9, }, ]; @@ -251,6 +259,8 @@ interface HostInfo { id: string; status: HostPolicyResponseActionStatus; name: string; + endpoint_policy_version: number; + version: number; }; }; }; @@ -1332,7 +1342,7 @@ export class EndpointDocGenerator { allStatus?: HostPolicyResponseActionStatus; policyDataStream?: DataStream; } = {}): HostPolicyResponse { - const policyVersion = this.seededUUIDv4(); + const policyVersion = this.randomN(10); const status = () => { return allStatus || this.randomHostPolicyResponseActionStatus(); }; @@ -1501,6 +1511,8 @@ export class EndpointDocGenerator { status: this.commonInfo.Endpoint.policy.applied.status, version: policyVersion, name: this.commonInfo.Endpoint.policy.applied.name, + endpoint_policy_version: this.commonInfo.Endpoint.policy.applied + .endpoint_policy_version, }, }, }, 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 66ba15431e6031..f873a701eb9bd3 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -299,6 +299,8 @@ export interface HostResultList { request_page_index: number; /* the version of the query strategy */ query_strategy_version: MetadataQueryStrategyVersions; + /* policy IDs and versions */ + policy_info?: HostInfo['policy_info']; } /** @@ -520,9 +522,30 @@ export enum MetadataQueryStrategyVersions { VERSION_2 = 'v2', } +export type PolicyInfo = Immutable<{ + revision: number; + id: string; +}>; + export type HostInfo = Immutable<{ metadata: HostMetadata; host_status: HostStatus; + policy_info?: { + agent: { + /** + * As set in Kibana + */ + configured: PolicyInfo; + /** + * Last reported running in agent (may lag behind configured) + */ + applied: PolicyInfo; + }; + /** + * Current intended 'endpoint' package policy + */ + endpoint: PolicyInfo; + }; /* the version of the query strategy */ query_strategy_version: MetadataQueryStrategyVersions; }>; @@ -558,6 +581,8 @@ export type HostMetadata = Immutable<{ id: string; status: HostPolicyResponseActionStatus; name: string; + endpoint_policy_version: number; + version: number; }; }; }; @@ -1068,7 +1093,8 @@ export interface HostPolicyResponse { Endpoint: { policy: { applied: { - version: string; + version: number; + endpoint_policy_version: number; id: string; name: string; status: HostPolicyResponseActionStatus; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 3888d37a547f7a..967b3870cb9e00 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -401,3 +401,14 @@ export const importTimelineResultSchema = runtimeTypes.exact( export type ImportTimelineResultSchema = runtimeTypes.TypeOf<typeof importTimelineResultSchema>; export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom'; + +export interface TimelineExpandedEventType { + eventId: string; + indexName: string; + loading: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EmptyObject = Record<any, never>; + +export type TimelineExpandedEvent = TimelineExpandedEventType | EmptyObject; diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index fb1f2920aaceb1..596b92d064050f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -215,7 +215,8 @@ describe('Custom detection rules creation', () => { }); }); -describe('Custom detection rules deletion and edition', () => { +// FLAKY: https://github.com/elastic/kibana/issues/83772 +describe.skip('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts index eb8448233c6241..c2be6b2883c884 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts @@ -17,7 +17,8 @@ import { DETECTIONS_URL } from '../urls/navigation'; const EXPECTED_EXPORTED_RULE_FILE_PATH = 'cypress/test_files/expected_rules_export.ndjson'; -describe('Export rules', () => { +// FLAKY: https://github.com/elastic/kibana/issues/69849 +describe.skip('Export rules', () => { before(() => { esArchiverLoad('export_rule'); cy.server(); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index 6b1f3699d333a7..dd01159e3029fa 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -10,10 +10,9 @@ export const openTimelineUsingToggle = () => { cy.get(TIMELINE_TOGGLE_BUTTON).click(); }; -export const openTimelineIfClosed = () => { +export const openTimelineIfClosed = () => cy.get(MAIN_PAGE).then(($page) => { if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { openTimelineUsingToggle(); } }); -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index c54bd8b621d835..859ba3d1a0951d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -8,7 +8,7 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; -import { CommentRequest, CommentType } from '../../../../../case/common/api'; +import { CommentType } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; @@ -16,7 +16,7 @@ import { useInsertTimeline } from '../../../timelines/components/timeline/insert import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; -import { schema } from './schema'; +import { schema, AddCommentFormSchema } from './schema'; import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` @@ -25,9 +25,8 @@ const MySpinner = styled(EuiLoadingSpinner)` left: 50%; `; -const initialCommentValue: CommentRequest = { +const initialCommentValue: AddCommentFormSchema = { comment: '', - type: CommentType.user, }; export interface AddCommentRefObject { @@ -47,7 +46,7 @@ export const AddComment = React.memo( ({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => { const { isLoading, postComment } = usePostComment(caseId); - const { form } = useForm<CommentRequest>({ + const { form } = useForm<AddCommentFormSchema>({ defaultValue: initialCommentValue, options: { stripEmptyFields: false }, schema, diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx index eb11357cd7ce94..5f244d64701fe4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/schema.tsx @@ -4,13 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CommentRequest } from '../../../../../case/common/api'; +import { CommentRequestUserType } from '../../../../../case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; import * as i18n from './translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema<CommentRequest> = { +export interface AddCommentFormSchema { + comment: CommentRequestUserType['comment']; +} + +export const schema: FormSchema<AddCommentFormSchema> = { comment: { type: FIELD_TYPES.TEXTAREA, validations: [ diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index de3e9c07ae8a38..228f3a4319c338 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -380,7 +380,7 @@ export const UserActionTree = React.memo( ]; } - // description, comments, tags + // title, description, comments, tags if ( action.actionField.length === 1 && ['title', 'description', 'comment', 'tags'].includes(action.actionField[0]) diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index 0d5bf13cd62618..0d2df7c2de3ea0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -348,6 +348,7 @@ describe('Case Configuration API', () => { method: 'PATCH', body: JSON.stringify({ comment: 'updated comment', + type: CommentType.user, id: basicCase.comments[0].id, version: basicCase.comments[0].version, }), @@ -404,7 +405,7 @@ describe('Case Configuration API', () => { }); const data = { comment: 'comment', - type: CommentType.user, + type: CommentType.user as const, }; test('check url, method, signal', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 83ee10e9b45a82..6046c3716b3b5b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -11,13 +11,14 @@ import { CasePatchRequest, CasePostRequest, CasesStatusResponse, - CommentRequest, + CommentRequestUserType, User, CaseUserActionsResponse, CaseExternalServiceRequest, ServiceConnectorCaseParams, ServiceConnectorCaseResponse, ActionTypeExecutorResult, + CommentType, } from '../../../../case/common/api'; import { @@ -181,7 +182,7 @@ export const patchCasesStatus = async ( }; export const postComment = async ( - newComment: CommentRequest, + newComment: CommentRequestUserType, caseId: string, signal: AbortSignal ): Promise<Case> => { @@ -205,7 +206,12 @@ export const patchComment = async ( ): Promise<Case> => { const response = await KibanaServices.get().http.fetch<CaseResponse>(getCaseCommentsUrl(caseId), { method: 'PATCH', - body: JSON.stringify({ comment: commentUpdate, id: commentId, version }), + body: JSON.stringify({ + comment: commentUpdate, + type: CommentType.user, + id: commentId, + version, + }), signal, }); return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response)); diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index c2ddcce8b1d3ce..b9db356498a01b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -19,7 +19,7 @@ export interface Comment { createdAt: string; createdBy: ElasticUser; comment: string; - type: CommentType; + type: CommentType.user; pushedAt: string | null; pushedBy: string | null; updatedAt: string | null; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx index 773d4b8d1fe56d..39ee21f942cbd0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx @@ -17,7 +17,7 @@ describe('usePostComment', () => { const abortCtrl = new AbortController(); const samplePost = { comment: 'a comment', - type: CommentType.user, + type: CommentType.user as const, }; const updateCaseCallback = jest.fn(); beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index e6cb8a9c3d150e..cd3827a2887fb6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -6,7 +6,7 @@ import { useReducer, useCallback } from 'react'; -import { CommentRequest } from '../../../../case/common/api'; +import { CommentRequestUserType } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postComment } from './api'; @@ -42,7 +42,7 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta }; export interface UsePostComment extends NewCommentState { - postComment: (data: CommentRequest, updateCase: (newCase: Case) => void) => void; + postComment: (data: CommentRequestUserType, updateCase: (newCase: Case) => void) => void; } export const usePostComment = (caseId: string): UsePostComment => { @@ -53,7 +53,7 @@ export const usePostComment = (caseId: string): UsePostComment => { const [, dispatchToaster] = useStateToaster(); const postMyComment = useCallback( - async (data: CommentRequest, updateCase: (newCase: Case) => void) => { + async (data: CommentRequestUserType, updateCase: (newCase: Case) => void) => { let cancel = false; const abortCtrl = new AbortController(); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index 2ae621e71a7252..9ca9cd6cce3893 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -1544,12 +1544,5 @@ In other use cases the message field can be used to concatenate different values ] } /> - <CollapseLink - aria-label="Collapse" - data-test-subj="collapse" - onClick={[MockFunction]} - > - Collapse event - </CollapseLink> </Details> `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 7b6e9fb21a3e33..35cb8f7b1c91f7 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -12,8 +12,8 @@ import { EuiFlexItem, EuiIcon, EuiPanel, - EuiText, EuiToolTip, + EuiIconTip, } from '@elastic/eui'; import React from 'react'; import { Draggable } from 'react-beautiful-dnd'; @@ -27,7 +27,6 @@ import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; import { getDroppableId, getDraggableFieldId, DRAG_TYPE_FIELD } from '../drag_and_drop/helpers'; import { DraggableFieldBadge } from '../draggables/field_badge'; import { FieldName } from '../../../timelines/components/fields_browser/field_name'; -import { SelectableText } from '../selectable_text'; import { OverflowField } from '../tables/helpers'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; @@ -90,6 +89,21 @@ export const getColumns = ({ </EuiToolTip> ), }, + { + field: 'description', + name: '', + render: (description: string | null | undefined, data: EventFieldsData) => ( + <EuiIconTip + aria-label={i18n.DESCRIPTION} + type="iInCircle" + color="subdued" + content={`${description || ''} ${getExampleText(data.example)}`} + /> + ), + sortable: true, + truncateText: true, + width: '30px', + }, { field: 'field', name: i18n.FIELD, @@ -187,18 +201,6 @@ export const getColumns = ({ </EuiFlexGroup> ), }, - { - field: 'description', - name: i18n.DESCRIPTION, - render: (description: string | null | undefined, data: EventFieldsData) => ( - <SelectableText> - <EuiText size="xs">{`${description || ''} ${getExampleText(data.example)}`}</EuiText> - </SelectableText> - ), - sortable: true, - truncateText: true, - width: '50%', - }, { field: 'valuesConcatenated', name: i18n.BLANK, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index c3c7c864ac99b7..bafe3df1a9cc7e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -23,14 +23,12 @@ import { useMountAppended } from '../../utils/use_mount_appended'; jest.mock('../link_to'); describe('EventDetails', () => { const mount = useMountAppended(); - const onEventToggled = jest.fn(); const defaultProps = { browserFields: mockBrowserFields, columnHeaders: defaultHeaders, data: mockDetailItemData, id: mockDetailItemDataId, view: 'table-view' as View, - onEventToggled, onUpdateColumns: jest.fn(), onViewSelected: jest.fn(), timelineId: 'test', @@ -66,12 +64,5 @@ describe('EventDetails', () => { wrapper.find('[data-test-subj="eventDetails"]').find('.euiTab-isSelected').first().text() ).toEqual('Table'); }); - - test('it invokes `onEventToggled` when the collapse button is clicked', () => { - wrapper.find('[data-test-subj="collapse"]').first().simulate('click'); - wrapper.update(); - - expect(onEventToggled).toHaveBeenCalled(); - }); }); }); 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 074e6faf80c7d9..a2a7182a768ccc 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 @@ -5,7 +5,7 @@ */ import { EuiLink, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -15,9 +15,12 @@ import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; -import { COLLAPSE, COLLAPSE_EVENT } from '../../../timelines/components/timeline/body/translations'; -export type View = 'table-view' | 'json-view'; +export type View = EventsViewType.tableView | EventsViewType.jsonView; +export enum EventsViewType { + tableView = 'table-view', + jsonView = 'json-view', +} const CollapseLink = styled(EuiLink)` margin: 20px 0; @@ -30,10 +33,9 @@ interface Props { columnHeaders: ColumnHeaderOptions[]; data: TimelineEventsDetailsItem[]; id: string; - view: View; - onEventToggled: () => void; + view: EventsViewType; onUpdateColumns: OnUpdateColumns; - onViewSelected: (selected: View) => void; + onViewSelected: (selected: EventsViewType) => void; timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; } @@ -51,16 +53,19 @@ export const EventDetails = React.memo<Props>( data, id, view, - onEventToggled, onUpdateColumns, onViewSelected, timelineId, toggleColumn, }) => { + const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [ + onViewSelected, + ]); + const tabs: EuiTabbedContentTab[] = useMemo( () => [ { - id: 'table-view', + id: EventsViewType.tableView, name: i18n.TABLE, content: ( <EventFieldsBrowser @@ -75,7 +80,7 @@ export const EventDetails = React.memo<Props>( ), }, { - id: 'json-view', + id: EventsViewType.jsonView, name: i18n.JSON_VIEW, content: <JsonView data={data} />, }, @@ -88,11 +93,8 @@ export const EventDetails = React.memo<Props>( <EuiTabbedContent tabs={tabs} selectedTab={view === 'table-view' ? tabs[0] : tabs[1]} - onTabClick={(e) => onViewSelected(e.id as View)} + onTabClick={handleTabClick} /> - <CollapseLink aria-label={COLLAPSE} data-test-subj="collapse" onClick={onEventToggled}> - {COLLAPSE_EVENT} - </CollapseLink> </Details> ); } diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx index 77d0ec330476c5..0acf461828bc37 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx @@ -21,7 +21,7 @@ describe('EventFieldsBrowser', () => { const mount = useMountAppended(); describe('column headers', () => { - ['Field', 'Value', 'Description'].forEach((header) => { + ['Field', 'Value'].forEach((header) => { test(`it renders the ${header} column header`, () => { const wrapper = mount( <TestProviders> @@ -229,8 +229,15 @@ describe('EventFieldsBrowser', () => { </TestProviders> ); - expect(wrapper.find('.euiTableRow').find('.euiTableRowCell').at(3).text()).toContain( - 'DescriptionDate/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' + expect( + wrapper + .find('.euiTableRow') + .find('.euiTableRowCell') + .at(1) + .find('EuiIconTip') + .prop('content') + ).toContain( + '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' ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx index bb74935d5703eb..4730dc5c2264f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx @@ -4,49 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../containers/source'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; -import { EventDetails, View } from './event_details'; +import { EventDetails, EventsViewType, View } from './event_details'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; data: TimelineEventsDetailsItem[]; id: string; - onEventToggled: () => void; onUpdateColumns: OnUpdateColumns; timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; } export const StatefulEventDetails = React.memo<Props>( - ({ - browserFields, - columnHeaders, - data, - id, - onEventToggled, - onUpdateColumns, - timelineId, - toggleColumn, - }) => { - const [view, setView] = useState<View>('table-view'); + ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { + // TODO: Move to the store + const [view, setView] = useState<View>(EventsViewType.tableView); - const handleSetView = useCallback((newView) => setView(newView), []); return ( <EventDetails browserFields={browserFields} columnHeaders={columnHeaders} data={data} id={id} - onEventToggled={onEventToggled} onUpdateColumns={onUpdateColumns} - onViewSelected={handleSetView} + onViewSelected={setView} timelineId={timelineId} toggleColumn={toggleColumn} view={view} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx new file mode 100644 index 00000000000000..ad332b2759048c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; + +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { BrowserFields, DocValueFields } from '../../containers/source'; +import { + ExpandableEvent, + ExpandableEventTitle, +} from '../../../timelines/components/timeline/expandable_event'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; + +const StyledEuiFlyout = styled(EuiFlyout)` + z-index: 9999; +`; + +interface EventDetailsFlyoutProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + timelineId: string; + toggleColumn: (column: ColumnHeaderOptions) => void; +} + +const EventDetailsFlyoutComponent: React.FC<EventDetailsFlyoutProps> = ({ + browserFields, + docValueFields, + timelineId, + toggleColumn, +}) => { + const dispatch = useDispatch(); + const expandedEvent = useDeepEqualSelector( + (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? {} + ); + + const handleClearSelection = useCallback(() => { + dispatch( + timelineActions.toggleExpandedEvent({ + timelineId, + event: {}, + }) + ); + }, [dispatch, timelineId]); + + if (!expandedEvent.eventId) { + return null; + } + + return ( + <StyledEuiFlyout size="s" onClose={handleClearSelection}> + <EuiFlyoutHeader hasBorder> + <ExpandableEventTitle /> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <ExpandableEvent + browserFields={browserFields} + docValueFields={docValueFields} + event={expandedEvent} + timelineId={timelineId} + toggleColumn={toggleColumn} + /> + </EuiFlyoutBody> + </StyledEuiFlyout> + ); +}; + +export const EventDetailsFlyout = React.memo( + EventDetailsFlyoutComponent, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.docValueFields, nextProps.docValueFields) && + prevProps.timelineId === nextProps.timelineId && + prevProps.toggleColumn === nextProps.toggleColumn +); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 421b111d7941fd..186083f1b05cdb 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -36,7 +36,8 @@ import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useFullScreen } from '../../containers/use_full_screen'; -import { TimelineId } from '../../../../common/types/timeline'; +import { TimelineId, TimelineType } from '../../../../common/types/timeline'; +import { GraphOverlay } from '../../../timelines/components/graph_overlay'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -76,6 +77,16 @@ const EventsContainerLoading = styled.div` flex-direction: column; `; +const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` + width: 100%; + overflow: hidden; + display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: auto; +`; + /** * Hides stateful headerFilterGroup implementations, but prevents the component * from being unmounted, to preserve the state of the component @@ -280,21 +291,27 @@ const EventsViewerComponent: React.FC<Props> = ({ refetch={refetch} /> - <StatefulBody - browserFields={browserFields} - data={nonDeletedEvents} - docValueFields={docValueFields} - id={id} - isEventViewer={true} - onRuleChange={onRuleChange} - refetch={refetch} - sort={sort} - toggleColumn={toggleColumn} - /> - - { - /** Hide the footer if Resolver is showing. */ - !graphEventId && ( + {graphEventId && ( + <GraphOverlay + graphEventId={graphEventId} + isEventViewer={true} + timelineId={id} + timelineType={TimelineType.default} + /> + )} + <FullWidthFlexGroup $visible={!graphEventId}> + <ScrollableFlexItem grow={1}> + <StatefulBody + browserFields={browserFields} + data={nonDeletedEvents} + docValueFields={docValueFields} + id={id} + isEventViewer={true} + onRuleChange={onRuleChange} + refetch={refetch} + sort={sort} + toggleColumn={toggleColumn} + /> <Footer activePage={pageInfo.activePage} data-test-subj="events-viewer-footer" @@ -310,8 +327,8 @@ const EventsViewerComponent: React.FC<Props> = ({ onChangePage={loadPage} totalCount={totalCountMinusDeleted} /> - ) - } + </ScrollableFlexItem> + </FullWidthFlexGroup> </EventsContainerLoading> </> </EventDetailsWidthProvider> 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 a4f2b0536abf5c..58f81c9fb3c8bb 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 @@ -24,6 +24,7 @@ import { InspectButtonContainer } from '../inspect'; import { useFullScreen } from '../../containers/use_full_screen'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; +import { EventDetailsFlyout } from './event_details_flyout'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 652; @@ -134,36 +135,44 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({ const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); return ( - <FullScreenContainer $isFullScreen={globalFullScreen}> - <InspectButtonContainer> - <EventsViewer - browserFields={browserFields} - columns={columns} - docValueFields={docValueFields} - id={id} - dataProviders={dataProviders!} - deletedEventIds={deletedEventIds} - end={end} - isLoadingIndexPattern={isLoadingIndexPattern} - filters={globalFilters} - headerFilterGroup={headerFilterGroup} - indexNames={selectedPatterns} - indexPattern={indexPattern} - isLive={isLive} - itemsPerPage={itemsPerPage!} - itemsPerPageOptions={itemsPerPageOptions!} - kqlMode={kqlMode} - onChangeItemsPerPage={onChangeItemsPerPage} - query={query} - onRuleChange={onRuleChange} - start={start} - sort={sort} - toggleColumn={toggleColumn} - utilityBar={utilityBar} - graphEventId={graphEventId} - /> - </InspectButtonContainer> - </FullScreenContainer> + <> + <FullScreenContainer $isFullScreen={globalFullScreen}> + <InspectButtonContainer> + <EventsViewer + browserFields={browserFields} + columns={columns} + docValueFields={docValueFields} + id={id} + dataProviders={dataProviders!} + deletedEventIds={deletedEventIds} + end={end} + isLoadingIndexPattern={isLoadingIndexPattern} + filters={globalFilters} + headerFilterGroup={headerFilterGroup} + indexNames={selectedPatterns} + indexPattern={indexPattern} + isLive={isLive} + itemsPerPage={itemsPerPage!} + itemsPerPageOptions={itemsPerPageOptions!} + kqlMode={kqlMode} + onChangeItemsPerPage={onChangeItemsPerPage} + query={query} + onRuleChange={onRuleChange} + start={start} + sort={sort} + toggleColumn={toggleColumn} + utilityBar={utilityBar} + graphEventId={graphEventId} + /> + </InspectButtonContainer> + </FullScreenContainer> + <EventDetailsFlyout + browserFields={browserFields} + docValueFields={docValueFields} + timelineId={id} + toggleColumn={toggleColumn} + /> + </> ); }; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 0944b6aa27f678..ba375612b22a71 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DEFAULT_TIMELINE_WIDTH } from '../../timelines/components/timeline/body/constants'; import { Direction, FlowTarget, @@ -213,6 +212,7 @@ export const mockGlobalState: State = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], + expandedEvent: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, @@ -238,7 +238,6 @@ export const mockGlobalState: State = { pinnedEventsSaveObject: {}, itemsPerPageOptions: [5, 10, 20], sort: { columnId: '@timestamp', sortDirection: Direction.desc }, - width: DEFAULT_TIMELINE_WIDTH, isSaving: false, version: null, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index ed226fb0c984fe..0118004b48eb8e 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2100,6 +2100,7 @@ export const mockTimelineModel: TimelineModel = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [ { $state: { @@ -2150,7 +2151,6 @@ export const mockTimelineModel: TimelineModel = { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }; export const mockTimelineResult: TimelineResult = { @@ -2220,6 +2220,7 @@ export const defaultTimelineProps: CreateTimelineProps = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -2252,7 +2253,6 @@ export const defaultTimelineProps: CreateTimelineProps = { templateTimelineVersion: null, templateTimelineId: null, version: null, - width: 1100, }, to: '2018-11-05T19:03:25.937Z', notes: null, diff --git a/x-pack/plugins/security_solution/public/common/utils/route/index.test.tsx b/x-pack/plugins/security_solution/public/common/utils/route/index.test.tsx index 7246259f5afa1b..ac8c78b1fdbd4b 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/route/index.test.tsx @@ -188,6 +188,9 @@ describe('Spy Routes', () => { }); wrapper.update(); expect(dispatchMock.mock.calls[0]).toEqual([ + { type: 'updateSearch', search: '?updated="true"' }, + ]); + expect(dispatchMock.mock.calls[1]).toEqual([ { route: { detailName: undefined, diff --git a/x-pack/plugins/security_solution/public/common/utils/route/spy_routes.tsx b/x-pack/plugins/security_solution/public/common/utils/route/spy_routes.tsx index febcf0aee679df..5450a6ec1a3138 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/spy_routes.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/route/spy_routes.tsx @@ -35,6 +35,11 @@ export const SpyRouteComponent = memo< search, }); setIsInitializing(false); + } else if (search !== '' && search !== route.search) { + dispatch({ + type: 'updateSearch', + search, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [search]); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index ecc0fc54d0d47a..6b7cc8167ede6d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -190,6 +190,7 @@ describe('alert actions', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [ { $state: { @@ -253,7 +254,6 @@ describe('alert actions', () => { templateTimelineId: null, templateTimelineVersion: null, version: null, - width: 1100, }, to: '2018-11-05T19:03:25.937Z', ruleNote: '# this is some markdown documentation', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 84d1dabe869105..2e9206d945cad8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -63,6 +63,7 @@ describe('EndpointList store concerns', () => { agentsWithEndpointsTotalError: undefined, endpointsTotalError: undefined, queryStrategyVersion: undefined, + policyVersionInfo: undefined, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 26d8dda2f4aec1..33772f4463543f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -41,6 +41,7 @@ export const initialEndpointListState: Immutable<EndpointState> = { endpointsTotal: 0, endpointsTotalError: undefined, queryStrategyVersion: undefined, + policyVersionInfo: undefined, }; /* eslint-disable-next-line complexity */ @@ -55,6 +56,7 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = ( request_page_size: pageSize, request_page_index: pageIndex, query_strategy_version: queryStrategyVersion, + policy_info: policyVersionInfo, } = action.payload; return { ...state, @@ -63,6 +65,7 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = ( pageSize, pageIndex, queryStrategyVersion, + policyVersionInfo, loading: false, error: undefined, }; @@ -104,6 +107,7 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = ( return { ...state, details: action.payload.metadata, + policyVersionInfo: action.payload.policy_info, detailsLoading: false, detailsError: undefined, }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 29d9185b6cea55..1901f3589104a5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -55,6 +55,8 @@ export const isAutoRefreshEnabled = (state: Immutable<EndpointState>) => state.i export const autoRefreshInterval = (state: Immutable<EndpointState>) => state.autoRefreshInterval; +export const policyVersionInfo = (state: Immutable<EndpointState>) => state.policyVersionInfo; + export const areEndpointsEnrolling = (state: Immutable<EndpointState>) => { return state.agentsWithEndpointsTotal > state.endpointsTotal; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index ec22c522c3d0a9..63ec991ecf6d1a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -76,6 +76,8 @@ export interface EndpointState { endpointsTotalError?: ServerApiError; /** The query strategy version that informs whether the transform for KQL is enabled or not */ queryStrategyVersion?: MetadataQueryStrategyVersions; + /** The policy IDs and revision number of the corresponding agent, and endpoint. May be more recent than what's running */ + policyVersionInfo?: HostInfo['policy_info']; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts new file mode 100644 index 00000000000000..ce6d2f354cc458 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HostInfo, HostMetadata } from '../../../../common/endpoint/types'; + +export const isPolicyOutOfDate = ( + reported: HostMetadata['Endpoint']['policy']['applied'], + current: HostInfo['policy_info'] +): boolean => { + if (current === undefined || current === null) { + return false; // we don't know, can't declare it out-of-date + } + return !( + reported.id === current.endpoint.id && // endpoint package policy not reassigned + current.agent.configured.id === current.agent.applied.id && // agent policy wasn't reassigned and not-yet-applied + // all revisions match up + reported.version >= current.agent.applied.revision && + reported.version >= current.agent.configured.revision && + reported.endpoint_policy_version >= current.endpoint.revision + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx new file mode 100644 index 00000000000000..6718dfe4cb9b48 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const OutOfDate = React.memo<{ style?: React.CSSProperties }>(({ style, ...otherProps }) => { + return ( + <EuiText color="subdued" size="xs" className="eui-textNoWrap" style={style} {...otherProps}> + <EuiIcon size="m" type="alert" color="warning" /> + <FormattedMessage id="xpack.securitySolution.outOfDateLabel" defaultMessage="Out-of-date" /> + </EuiText> + ); +}); + +OutOfDate.displayName = 'OutOfDate'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index dd7475361b950b..dbb242845626ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -18,7 +18,8 @@ import { import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { isPolicyOutOfDate } from '../../utils'; +import { HostInfo, HostMetadata } from '../../../../../../common/endpoint/types'; import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; @@ -31,6 +32,7 @@ import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { AgentDetailsReassignPolicyAction } from '../../../../../../../fleet/public'; import { EndpointPolicyLink } from '../components/endpoint_policy_link'; +import { OutOfDate } from '../components/out_of_date'; const HostIds = styled(EuiListGroupItem)` margin-top: 0; @@ -51,187 +53,190 @@ const LinkToExternalApp = styled.div` const openReassignFlyoutSearch = '?openReassignFlyout=true'; -export const EndpointDetails = memo(({ details }: { details: HostMetadata }) => { - const agentId = details.elastic.agent.id; - const { - url: agentDetailsUrl, - appId: ingestAppId, - appPath: agentDetailsAppPath, - } = useAgentDetailsIngestUrl(agentId); - const queryParams = useEndpointSelector(uiQueryParams); - const policyStatus = useEndpointSelector( - policyResponseStatus - ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; - const { formatUrl } = useFormatUrl(SecurityPageName.administration); +export const EndpointDetails = memo( + ({ details, policyInfo }: { details: HostMetadata; policyInfo?: HostInfo['policy_info'] }) => { + const agentId = details.elastic.agent.id; + const { + url: agentDetailsUrl, + appId: ingestAppId, + appPath: agentDetailsAppPath, + } = useAgentDetailsIngestUrl(agentId); + const queryParams = useEndpointSelector(uiQueryParams); + const policyStatus = useEndpointSelector( + policyResponseStatus + ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; + const { formatUrl } = useFormatUrl(SecurityPageName.administration); - const detailsResultsUpper = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.os', { - defaultMessage: 'OS', - }), - description: details.host.os.full, - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { - defaultMessage: 'Last Seen', - }), - description: <FormattedDateAndTime date={new Date(details['@timestamp'])} />, - }, - ]; - }, [details]); + const detailsResultsUpper = useMemo(() => { + return [ + { + title: i18n.translate('xpack.securitySolution.endpoint.details.os', { + defaultMessage: 'OS', + }), + description: details.host.os.full, + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { + defaultMessage: 'Last Seen', + }), + description: <FormattedDateAndTime date={new Date(details['@timestamp'])} />, + }, + ]; + }, [details]); - const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { selected_endpoint, show, ...currentUrlParams } = queryParams; - return [ - formatUrl( + const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { selected_endpoint, show, ...currentUrlParams } = queryParams; + return [ + formatUrl( + getEndpointDetailsPath({ + name: 'endpointPolicyResponse', + ...currentUrlParams, + selected_endpoint: details.agent.id, + }) + ), getEndpointDetailsPath({ name: 'endpointPolicyResponse', ...currentUrlParams, selected_endpoint: details.agent.id, - }) - ), - getEndpointDetailsPath({ - name: 'endpointPolicyResponse', - ...currentUrlParams, - selected_endpoint: details.agent.id, - }), - ]; - }, [details.agent.id, formatUrl, queryParams]); + }), + ]; + }, [details.agent.id, formatUrl, queryParams]); + + const agentDetailsWithFlyoutPath = `${agentDetailsAppPath}${openReassignFlyoutSearch}`; + const agentDetailsWithFlyoutUrl = `${agentDetailsUrl}${openReassignFlyoutSearch}`; + const handleReassignEndpointsClick = useNavigateToAppEventHandler< + AgentDetailsReassignPolicyAction + >(ingestAppId, { + path: agentDetailsWithFlyoutPath, + state: { + onDoneNavigateTo: [ + 'securitySolution:administration', + { + path: getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: details.agent.id, + }), + }, + ], + }, + }); - const agentDetailsWithFlyoutPath = `${agentDetailsAppPath}${openReassignFlyoutSearch}`; - const agentDetailsWithFlyoutUrl = `${agentDetailsUrl}${openReassignFlyoutSearch}`; - const handleReassignEndpointsClick = useNavigateToAppEventHandler< - AgentDetailsReassignPolicyAction - >(ingestAppId, { - path: agentDetailsWithFlyoutPath, - state: { - onDoneNavigateTo: [ - 'securitySolution:administration', + const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); + + const detailsResultsPolicy = useMemo(() => { + return [ { - path: getEndpointDetailsPath({ - name: 'endpointDetails', - selected_endpoint: details.agent.id, + title: i18n.translate('xpack.securitySolution.endpoint.details.policy', { + defaultMessage: 'Integration Policy', }), + description: ( + <> + <EndpointPolicyLink + policyId={details.Endpoint.policy.applied.id} + data-test-subj="policyDetailsValue" + > + {details.Endpoint.policy.applied.name} + </EndpointPolicyLink> + {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && <OutOfDate />} + </> + ), }, - ], - }, - }); - - const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - - const detailsResultsPolicy = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.policy', { - defaultMessage: 'Integration Policy', - }), - description: ( - <> - <EndpointPolicyLink - policyId={details.Endpoint.policy.applied.id} - data-test-subj="policyDetailsValue" - > - {details.Endpoint.policy.applied.name} - </EndpointPolicyLink> - </> - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', { - defaultMessage: 'Policy Response', - }), - description: ( - <EuiHealth - color={POLICY_STATUS_TO_HEALTH_COLOR[policyStatus] || 'subdued'} - data-test-subj="policyStatusHealth" - > - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - <EuiLink - data-test-subj="policyStatusValue" - href={policyResponseUri} - onClick={policyStatusClickHandler} + { + title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', { + defaultMessage: 'Policy Response', + }), + description: ( + <EuiHealth + color={POLICY_STATUS_TO_HEALTH_COLOR[policyStatus] || 'subdued'} + data-test-subj="policyStatusHealth" > - <EuiText size="m"> - <FormattedMessage - id="xpack.securitySolution.endpoint.details.policyStatusValue" - defaultMessage="{policyStatus, select, success {Success} warning {Warning} failure {Failed} other {Unknown}}" - values={{ policyStatus }} - /> - </EuiText> - </EuiLink> - </EuiHealth> - ), - }, - ]; - }, [details, policyResponseUri, policyStatus, policyStatusClickHandler]); - const detailsResultsLower = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', { - defaultMessage: 'IP Address', - }), - description: ( - <EuiListGroup flush> - {details.host.ip.map((ip: string, index: number) => ( - <HostIds key={index} label={ip} /> - ))} - </EuiListGroup> - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { - defaultMessage: 'Hostname', - }), - description: details.host.hostname, - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { - defaultMessage: 'Endpoint Version', - }), - description: details.agent.version, - }, - ]; - }, [details.agent.version, details.host.hostname, details.host.ip]); + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + <EuiLink + data-test-subj="policyStatusValue" + href={policyResponseUri} + onClick={policyStatusClickHandler} + > + <EuiText size="m"> + <FormattedMessage + id="xpack.securitySolution.endpoint.details.policyStatusValue" + defaultMessage="{policyStatus, select, success {Success} warning {Warning} failure {Failed} other {Unknown}}" + values={{ policyStatus }} + /> + </EuiText> + </EuiLink> + </EuiHealth> + ), + }, + ]; + }, [details, policyResponseUri, policyStatus, policyStatusClickHandler, policyInfo]); + const detailsResultsLower = useMemo(() => { + return [ + { + title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', { + defaultMessage: 'IP Address', + }), + description: ( + <EuiListGroup flush> + {details.host.ip.map((ip: string, index: number) => ( + <HostIds key={index} label={ip} /> + ))} + </EuiListGroup> + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { + defaultMessage: 'Hostname', + }), + description: details.host.hostname, + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { + defaultMessage: 'Endpoint Version', + }), + description: details.agent.version, + }, + ]; + }, [details.agent.version, details.host.hostname, details.host.ip]); - return ( - <> - <EuiDescriptionList - type="column" - listItems={detailsResultsUpper} - data-test-subj="endpointDetailsUpperList" - /> - <EuiHorizontalRule margin="m" /> - <EuiDescriptionList - type="column" - listItems={detailsResultsPolicy} - data-test-subj="endpointDetailsPolicyList" - /> - <LinkToExternalApp> - <LinkToApp - appId={ingestAppId} - appPath={agentDetailsWithFlyoutPath} - href={agentDetailsWithFlyoutUrl} - onClick={handleReassignEndpointsClick} - data-test-subj="endpointDetailsLinkToIngest" - > - <EuiIcon type="savedObjectsApp" className="linkToAppIcon" /> - <FormattedMessage - id="xpack.securitySolution.endpoint.details.linkToIngestTitle" - defaultMessage="Reassign Policy" - /> - <EuiIcon type="popout" className="linkToAppPopoutIcon" /> - </LinkToApp> - </LinkToExternalApp> - <EuiHorizontalRule margin="m" /> - <EuiDescriptionList - type="column" - listItems={detailsResultsLower} - data-test-subj="endpointDetailsLowerList" - /> - </> - ); -}); + return ( + <> + <EuiDescriptionList + type="column" + listItems={detailsResultsUpper} + data-test-subj="endpointDetailsUpperList" + /> + <EuiHorizontalRule margin="m" /> + <EuiDescriptionList + type="column" + listItems={detailsResultsPolicy} + data-test-subj="endpointDetailsPolicyList" + /> + <LinkToExternalApp> + <LinkToApp + appId={ingestAppId} + appPath={agentDetailsWithFlyoutPath} + href={agentDetailsWithFlyoutUrl} + onClick={handleReassignEndpointsClick} + data-test-subj="endpointDetailsLinkToIngest" + > + <EuiIcon type="savedObjectsApp" className="linkToAppIcon" /> + <FormattedMessage + id="xpack.securitySolution.endpoint.details.linkToIngestTitle" + defaultMessage="Reassign Policy" + /> + <EuiIcon type="popout" className="linkToAppPopoutIcon" /> + </LinkToApp> + </LinkToExternalApp> + <EuiHorizontalRule margin="m" /> + <EuiDescriptionList + type="column" + listItems={detailsResultsLower} + data-test-subj="endpointDetailsLowerList" + /> + </> + ); + } +); EndpointDetails.displayName = 'EndpointDetails'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 6bc3445c8e7458..edc15e22a699ee 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -33,6 +33,7 @@ import { policyResponseError, policyResponseLoading, policyResponseTimestamp, + policyVersionInfo, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; import { PolicyResponse } from './policy_response'; @@ -53,6 +54,7 @@ export const EndpointDetailsFlyout = memo(() => { ...queryParamsWithoutSelectedEndpoint } = queryParams; const details = useEndpointSelector(detailsData); + const policyInfo = useEndpointSelector(policyVersionInfo); const loading = useEndpointSelector(detailsLoading); const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); @@ -101,7 +103,7 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'details' && ( <> <EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody"> - <EndpointDetails details={details} /> + <EndpointDetails details={details} policyInfo={policyInfo} /> </EuiFlyoutBody> </> )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 4b955f2fe2959a..69889d3d0a881d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -228,15 +228,58 @@ describe('when on the list page', () => { firstPolicyID = hostListData[0].metadata.Endpoint.policy.applied.id; - [HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE, HostStatus.UNENROLLING].forEach( - (status, index) => { - hostListData[index] = { - metadata: hostListData[index].metadata, - host_status: status, - query_strategy_version: queryStrategyVersion, - }; - } - ); + // add ability to change (immutable) policy + type DeepMutable<T> = { -readonly [P in keyof T]: DeepMutable<T[P]> }; + type Policy = DeepMutable<NonNullable<HostInfo['policy_info']>>; + + const makePolicy = ( + applied: HostInfo['metadata']['Endpoint']['policy']['applied'], + cb: (policy: Policy) => Policy + ): Policy => { + return cb({ + agent: { + applied: { id: 'xyz', revision: applied.version }, + configured: { id: 'xyz', revision: applied.version }, + }, + endpoint: { id: applied.id, revision: applied.endpoint_policy_version }, + }); + }; + + [ + { status: HostStatus.ERROR, policy: (p: Policy) => p }, + { + status: HostStatus.ONLINE, + policy: (p: Policy) => { + p.endpoint.id = 'xyz'; // represents change in endpoint policy assignment + p.endpoint.revision = 1; + return p; + }, + }, + { + status: HostStatus.OFFLINE, + policy: (p: Policy) => { + p.endpoint.revision += 1; // changes made to endpoint policy + return p; + }, + }, + { + status: HostStatus.UNENROLLING, + policy: (p: Policy) => { + p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet + return p; + }, + }, + ].forEach((setup, index) => { + hostListData[index] = { + metadata: hostListData[index].metadata, + host_status: setup.status, + policy_info: makePolicy( + hostListData[index].metadata.Endpoint.policy.applied, + setup.policy + ), + query_strategy_version: queryStrategyVersion, + }; + }); hostListData.forEach((item, index) => { generatedPolicyStatuses[index] = item.metadata.Endpoint.policy.applied.status; }); @@ -316,6 +359,20 @@ describe('when on the list page', () => { }); }); + it('should display policy out-of-date warning when changes pending', async () => { + const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + }); + const outOfDates = await renderResult.findAllByTestId('rowPolicyOutOfDate'); + expect(outOfDates).toHaveLength(3); + + outOfDates.forEach((item, index) => { + expect(item.textContent).toEqual('Out-of-date'); + expect(item.querySelector(`[data-euiicon-type][color=warning]`)).not.toBeNull(); + }); + }); + it('should display policy name as a link', async () => { const renderResult = render(); await reactTestingLibrary.act(async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 2b40a7507da886..492b50af3dbd72 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -35,6 +35,7 @@ import { NavigateToAppOptions } from 'kibana/public'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; +import { isPolicyOutOfDate } from '../utils'; import { HOST_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_HEALTH_COLOR, @@ -57,6 +58,7 @@ import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/rou import { useFormatUrl } from '../../../../common/components/link_to'; import { EndpointAction } from '../store/action'; import { EndpointPolicyLink } from './components/endpoint_policy_link'; +import { OutOfDate } from './components/out_of_date'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -322,17 +324,22 @@ export const EndpointList = () => { }), truncateText: true, // eslint-disable-next-line react/display-name - render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied']) => { + render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { return ( - <EuiToolTip content={policy.name} anchorClassName="eui-textTruncate"> - <EndpointPolicyLink - policyId={policy.id} - className="eui-textTruncate" - data-test-subj="policyNameCellLink" - > - {policy.name} - </EndpointPolicyLink> - </EuiToolTip> + <> + <EuiToolTip content={policy.name} anchorClassName="eui-textTruncate"> + <EndpointPolicyLink + policyId={policy.id} + className="eui-textTruncate" + data-test-subj="policyNameCellLink" + > + {policy.name} + </EndpointPolicyLink> + </EuiToolTip> + {isPolicyOutOfDate(policy, item.policy_info) && ( + <OutOfDate style={{ paddingLeft: '6px' }} data-test-subj="rowPolicyOutOfDate" /> + )} + </> ); }, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 95ad5285507c5b..c163ab1ae448bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -7,7 +7,6 @@ import { mount, shallow } from 'enzyme'; import { set } from '@elastic/safer-lodash-set/fp'; import React from 'react'; -import { ActionCreator } from 'typescript-fsa'; import '../../../common/mock/react_beautiful_dnd'; import { @@ -20,10 +19,21 @@ import { } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers'; +import * as timelineActions from '../../store/timeline/actions'; -import { Flyout, FlyoutComponent } from '.'; +import { Flyout } from '.'; import { FlyoutButton } from './button'; +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + jest.mock('../timeline', () => ({ // eslint-disable-next-line react/display-name StatefulTimeline: () => <div />, @@ -35,6 +45,10 @@ describe('Flyout', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); + beforeEach(() => { + mockDispatch.mockClear(); + }); + describe('rendering', () => { test('it renders correctly against snapshot', () => { const wrapper = shallow( @@ -162,23 +176,15 @@ describe('Flyout', () => { }); test('should call the onOpen when the mouse is clicked for rendering', () => { - const showTimeline = (jest.fn() as unknown) as ActionCreator<{ id: string; show: boolean }>; const wrapper = mount( <TestProviders> - <FlyoutComponent - dataProviders={mockDataProviders} - show={false} - showTimeline={showTimeline} - timelineId="test" - width={100} - usersViewing={usersViewing} - /> + <Flyout timelineId="test" usersViewing={usersViewing} /> </TestProviders> ); wrapper.find('[data-test-subj="flyoutOverlay"]').first().simulate('click'); - expect(showTimeline).toBeCalled(); + expect(mockDispatch).toBeCalledWith(timelineActions.showTimeline({ id: 'test', show: true })); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index 7d0f5995afc3bc..f5ad6264f95e2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -6,17 +6,14 @@ import { EuiBadge } from '@elastic/eui'; import React, { useCallback } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { State } from '../../../common/store'; import { DataProvider } from '../timeline/data_providers/data_provider'; import { FlyoutButton } from './button'; import { Pane } from './pane'; import { timelineActions, timelineSelectors } from '../../store/timeline'; -import { DEFAULT_TIMELINE_WIDTH } from '../timeline/body/constants'; -import { StatefulTimeline } from '../timeline'; -import { TimelineById } from '../../store/timeline/types'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; export const Badge = (styled(EuiBadge)` position: absolute; @@ -40,66 +37,41 @@ interface OwnProps { usersViewing: string[]; } -type Props = OwnProps & ProsFromRedux; - -export const FlyoutComponent = React.memo<Props>( - ({ dataProviders, show = true, showTimeline, timelineId, usersViewing, width }) => { - const handleClose = useCallback(() => showTimeline({ id: timelineId, show: false }), [ - showTimeline, - timelineId, - ]); - const handleOpen = useCallback(() => showTimeline({ id: timelineId, show: true }), [ - showTimeline, - timelineId, - ]); - - return ( - <> - <Visible show={show}> - <Pane onClose={handleClose} timelineId={timelineId} width={width}> - <StatefulTimeline onClose={handleClose} usersViewing={usersViewing} id={timelineId} /> - </Pane> - </Visible> - <FlyoutButton - dataProviders={dataProviders} - show={!show} - timelineId={timelineId} - onOpen={handleOpen} - /> - </> - ); - } -); - -FlyoutComponent.displayName = 'FlyoutComponent'; - const DEFAULT_DATA_PROVIDERS: DataProvider[] = []; const DEFAULT_TIMELINE_BY_ID = {}; -const mapStateToProps = (state: State, { timelineId }: OwnProps) => { - const timelineById: TimelineById = - timelineSelectors.timelineByIdSelector(state) ?? DEFAULT_TIMELINE_BY_ID; - /* - In case timelineById[timelineId]?.dataProviders is an empty array it will cause unnecessary rerender - of StatefulTimeline which can be expensive, so to avoid that return DEFAULT_DATA_PROVIDERS - */ - const dataProviders = timelineById[timelineId]?.dataProviders.length - ? timelineById[timelineId]?.dataProviders - : DEFAULT_DATA_PROVIDERS; - const show = timelineById[timelineId]?.show ?? false; - const width = timelineById[timelineId]?.width ?? DEFAULT_TIMELINE_WIDTH; - - return { dataProviders, show, width }; -}; - -const mapDispatchToProps = { - showTimeline: timelineActions.showTimeline, +const FlyoutComponent: React.FC<OwnProps> = ({ timelineId, usersViewing }) => { + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const dispatch = useDispatch(); + const { dataProviders = DEFAULT_DATA_PROVIDERS, show = false } = useDeepEqualSelector( + (state) => getTimeline(state, timelineId) ?? DEFAULT_TIMELINE_BY_ID + ); + const handleClose = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), + [dispatch, timelineId] + ); + const handleOpen = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })), + [dispatch, timelineId] + ); + + return ( + <> + <Visible show={show}> + <Pane onClose={handleClose} timelineId={timelineId} usersViewing={usersViewing} /> + </Visible> + <FlyoutButton + dataProviders={dataProviders} + show={!show} + timelineId={timelineId} + onOpen={handleOpen} + /> + </> + ); }; -const connector = connect(mapStateToProps, mapDispatchToProps); - -type ProsFromRedux = ConnectedProps<typeof connector>; +FlyoutComponent.displayName = 'FlyoutComponent'; -export const Flyout = connector(FlyoutComponent); +export const Flyout = React.memo(FlyoutComponent); Flyout.displayName = 'Flyout'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap index f24ef3448d03f9..4a314d76a51bf6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/__snapshots__/index.test.tsx.snap @@ -4,10 +4,6 @@ exports[`Pane renders correctly against snapshot 1`] = ` <Pane onClose={[MockFunction]} timelineId="test" - width={640} -> - <span> - I am a child of flyout - </span> -</Pane> + usersViewing={Array []} +/> `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx index 3d2c42c33c9751..fed6a39ae2ed54 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx @@ -4,58 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { Pane } from '.'; -const testWidth = 640; - describe('Pane', () => { test('renders correctly against snapshot', () => { const EmptyComponent = shallow( <TestProviders> - <Pane onClose={jest.fn()} timelineId={'test'} width={testWidth}> - <span>{'I am a child of flyout'}</span> - </Pane> + <Pane onClose={jest.fn()} timelineId={'test'} usersViewing={[]} /> </TestProviders> ); expect(EmptyComponent.find('Pane')).toMatchSnapshot(); }); - - test('it should NOT let the flyout expand to take up the full width of the element that contains it', () => { - const wrapper = mount( - <TestProviders> - <Pane onClose={jest.fn()} timelineId={'test'} width={testWidth}> - <span>{'I am a child of flyout'}</span> - </Pane> - </TestProviders> - ); - - expect(wrapper.find('Resizable').get(0).props.maxWidth).toEqual('95vw'); - }); - - test('it should render a resize handle', () => { - const wrapper = mount( - <TestProviders> - <Pane onClose={jest.fn()} timelineId={'test'} width={testWidth}> - <span>{'I am a child of flyout'}</span> - </Pane> - </TestProviders> - ); - - expect(wrapper.find('[data-test-subj="flyout-resize-handle"]').first().exists()).toEqual(true); - }); - - test('it should render children', () => { - const wrapper = mount( - <TestProviders> - <Pane onClose={jest.fn()} timelineId={'test'} width={testWidth}> - <span>{'I am a mock body'}</span> - </Pane> - </TestProviders> - ); - expect(wrapper.first().text()).toContain('I am a mock body'); - }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 7528468ef65222..10eb1405158266 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -5,113 +5,48 @@ */ import { EuiFlyout } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; import styled from 'styled-components'; -import { Resizable, ResizeCallback } from 're-resizable'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; -import { useFullScreen } from '../../../../common/containers/use_full_screen'; -import { timelineActions } from '../../../store/timeline'; - -import { TimelineResizeHandle } from './timeline_resize_handle'; - +import { StatefulTimeline } from '../../timeline'; import * as i18n from './translations'; -const minWidthPixels = 550; // do not allow the flyout to shrink below this width (pixels) -const maxWidthPercent = 95; // do not allow the flyout to grow past this percentage of the view interface FlyoutPaneComponentProps { - children: React.ReactNode; onClose: () => void; timelineId: string; - width: number; + usersViewing: string[]; } const EuiFlyoutContainer = styled.div` .timeline-flyout { z-index: 4001; min-width: 150px; - width: auto; + width: 100%; animation: none; } `; -const StyledResizable = styled(Resizable)` - display: flex; - flex-direction: column; -`; - -const RESIZABLE_ENABLE = { left: true }; - -const RESIZABLE_DISABLED = { left: false }; - const FlyoutPaneComponent: React.FC<FlyoutPaneComponentProps> = ({ - children, onClose, timelineId, - width, -}) => { - const dispatch = useDispatch(); - const { timelineFullScreen } = useFullScreen(); - - const onResizeStop: ResizeCallback = useCallback( - (_e, _direction, _ref, delta) => { - const bodyClientWidthPixels = document.body.clientWidth; - - if (delta.width) { - dispatch( - timelineActions.applyDeltaToWidth({ - bodyClientWidthPixels, - delta: -delta.width, - id: timelineId, - maxWidthPercent, - minWidthPixels, - }) - ); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [dispatch] - ); - const resizableDefaultSize = useMemo( - () => ({ - width, - height: '100%', - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - const resizableHandleComponent = useMemo( - () => ({ - left: <TimelineResizeHandle data-test-subj="flyout-resize-handle" />, - }), - [] - ); - - return ( - <EuiFlyoutContainer data-test-subj="flyout-pane"> - <EuiFlyout - aria-label={i18n.TIMELINE_DESCRIPTION} - className="timeline-flyout" - data-test-subj="eui-flyout" - hideCloseButton={true} - onClose={onClose} - size="l" - > - <StyledResizable - enable={timelineFullScreen ? RESIZABLE_DISABLED : RESIZABLE_ENABLE} - defaultSize={resizableDefaultSize} - minWidth={timelineFullScreen ? 'calc(100vw - 8px)' : minWidthPixels} - maxWidth={timelineFullScreen ? 'calc(100vw - 8px)' : `${maxWidthPercent}vw`} - handleComponent={resizableHandleComponent} - onResizeStop={onResizeStop} - > - <EventDetailsWidthProvider>{children}</EventDetailsWidthProvider> - </StyledResizable> - </EuiFlyout> - </EuiFlyoutContainer> - ); -}; + usersViewing, +}) => ( + <EuiFlyoutContainer data-test-subj="flyout-pane"> + <EuiFlyout + aria-label={i18n.TIMELINE_DESCRIPTION} + className="timeline-flyout" + data-test-subj="eui-flyout" + hideCloseButton={true} + onClose={onClose} + size="l" + > + <EventDetailsWidthProvider> + <StatefulTimeline onClose={onClose} usersViewing={usersViewing} id={timelineId} /> + </EventDetailsWidthProvider> + </EuiFlyout> + </EuiFlyoutContainer> +); export const Pane = React.memo(FlyoutPaneComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx deleted file mode 100644 index 7192580f2426d3..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import styled from 'styled-components'; - -export const TIMELINE_RESIZE_HANDLE_WIDTH = 4; // px - -export const TimelineResizeHandle = styled.div` - background-color: ${({ theme }) => theme.eui.euiColorLightShade}; - cursor: col-resize; - min-height: 20px; - width: ${TIMELINE_RESIZE_HANDLE_WIDTH}px; - z-index: 2; - height: 100vh; - position: absolute; - &:hover { - background-color: ${({ theme }) => theme.eui.euiColorPrimary}; - } -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 921527a0079e33..20faf93616a8c2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -286,6 +286,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -321,7 +322,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); @@ -385,6 +385,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -420,7 +421,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); @@ -484,6 +484,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -519,7 +520,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); @@ -581,6 +581,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -616,7 +617,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); @@ -717,6 +717,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -749,7 +750,6 @@ describe('helpers', () => { sortDirection: 'desc', }, status: TimelineStatus.draft, - width: 1100, id: 'savedObject-1', }); }); @@ -841,6 +841,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [ { $state: { @@ -916,7 +917,6 @@ describe('helpers', () => { sortDirection: 'desc', }, status: TimelineStatus.draft, - width: 1100, id: 'savedObject-1', }); }); @@ -981,6 +981,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -1016,7 +1017,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); @@ -1080,6 +1080,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], + expandedEvent: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -1115,7 +1116,6 @@ describe('helpers', () => { templateTimelineId: null, templateTimelineVersion: null, version: '1', - width: 1100, }); }); }); 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 0afca363096595..a728e351220609 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 @@ -28,7 +28,6 @@ describe('Actions', () => { checked={false} expanded={false} eventId="abc" - loading={false} loadingEventIds={[]} onEventToggled={jest.fn()} onRowSelected={jest.fn()} @@ -46,29 +45,8 @@ describe('Actions', () => { <Actions actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} checked={false} - expanded={false} eventId="abc" - loading={false} - loadingEventIds={[]} - onEventToggled={jest.fn()} - onRowSelected={jest.fn()} - showCheckboxes={false} - /> - </TestProviders> - ); - - expect(wrapper.find('[data-test-subj="select-event"]').exists()).toBe(false); - }); - - test('it renders a button for expanding the event', () => { - const wrapper = mount( - <TestProviders> - <Actions - actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} - checked={false} expanded={false} - eventId="abc" - loading={false} loadingEventIds={[]} onEventToggled={jest.fn()} onRowSelected={jest.fn()} @@ -77,30 +55,6 @@ describe('Actions', () => { </TestProviders> ); - expect(wrapper.find('[data-test-subj="expand-event"]').exists()).toEqual(true); - }); - - test('it invokes onEventToggled when the button to expand an event is clicked', () => { - const onEventToggled = jest.fn(); - - const wrapper = mount( - <TestProviders> - <Actions - actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} - checked={false} - expanded={false} - eventId="abc" - loading={false} - loadingEventIds={[]} - onEventToggled={onEventToggled} - onRowSelected={jest.fn()} - showCheckboxes={false} - /> - </TestProviders> - ); - - wrapper.find('[data-test-subj="expand-event"]').first().simulate('click'); - - expect(onEventToggled).toBeCalled(); + expect(wrapper.find('[data-test-subj="select-event"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 3d08d56d6fb195..e942dce7245201 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -6,7 +6,7 @@ import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiLoadingSpinner, EuiCheckbox } from '@elastic/eui'; -import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from '../../styles'; +import { EventsTd, EventsTdContent, EventsTdGroupActions } from '../../styles'; import * as i18n from '../translations'; import { OnRowSelected } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; @@ -18,7 +18,6 @@ interface Props { onRowSelected: OnRowSelected; expanded: boolean; eventId: string; - loading: boolean; loadingEventIds: Readonly<string[]>; onEventToggled: () => void; showCheckboxes: boolean; @@ -30,7 +29,6 @@ const ActionsComponent: React.FC<Props> = ({ checked, expanded, eventId, - loading = false, loadingEventIds, onEventToggled, onRowSelected, @@ -68,17 +66,14 @@ const ActionsComponent: React.FC<Props> = ({ )} <EventsTd key="expand-event"> <EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}> - {loading ? ( - <EventsLoading /> - ) : ( - <EuiButtonIcon - aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND} - data-test-subj="expand-event" - iconType={expanded ? 'arrowDown' : 'arrowRight'} - id={eventId} - onClick={onEventToggled} - /> - )} + <EuiButtonIcon + aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND} + data-test-subj="expand-event" + disabled={expanded} + iconType="arrowRight" + id={eventId} + onClick={onEventToggled} + /> </EventsTdContent> </EventsTd> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index 576dedfc28b1b7..6fddb5403561e6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -20,5 +20,3 @@ export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px /** The default minimum width of a column of type `date` */ export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px - -export const DEFAULT_TIMELINE_WIDTH = 1100; // px diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index c6d4325f00739f..15d7d750257ac1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -46,7 +46,6 @@ interface Props { getNotesByIds: (noteIds: string[]) => Note[]; isEventPinned: boolean; isEventViewer?: boolean; - loading: boolean; loadingEventIds: Readonly<string[]>; onColumnResized: OnColumnResized; onEventToggled: () => void; @@ -81,7 +80,6 @@ export const EventColumnView = React.memo<Props>( getNotesByIds, isEventPinned = false, isEventViewer = false, - loading, loadingEventIds, onColumnResized, onEventToggled, @@ -194,7 +192,6 @@ export const EventColumnView = React.memo<Props>( expanded={expanded} data-test-subj="actions" eventId={id} - loading={loading} loadingEventIds={loadingEventIds} onEventToggled={onEventToggled} showCheckboxes={showCheckboxes} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx index 17dd83e9ab3f45..19d657b0537a5e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { inputsModel } from '../../../../../common/store'; -import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; +import { BrowserFields } from '../../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData, @@ -15,13 +15,7 @@ import { import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { Note } from '../../../../../common/lib/note'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { - OnColumnResized, - OnPinEvent, - OnRowSelected, - OnUnPinEvent, - OnUpdateColumns, -} from '../../events'; +import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { EventsTbody } from '../../styles'; import { ColumnRenderer } from '../renderers/column_renderer'; import { RowRenderer } from '../renderers/row_renderer'; @@ -34,9 +28,7 @@ interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; - containerElementRef: HTMLDivElement; data: TimelineItem[]; - docValueFields: DocValueFields[]; eventIdToNoteIds: Readonly<Record<string, string[]>>; getNotesByIds: (noteIds: string[]) => Note[]; id: string; @@ -45,7 +37,6 @@ interface Props { onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; - onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; pinnedEventIds: Readonly<Record<string, boolean>>; refetch: inputsModel.Refetch; @@ -63,9 +54,7 @@ const EventsComponent: React.FC<Props> = ({ browserFields, columnHeaders, columnRenderers, - containerElementRef, data, - docValueFields, eventIdToNoteIds, getNotesByIds, id, @@ -74,7 +63,6 @@ const EventsComponent: React.FC<Props> = ({ onColumnResized, onPinEvent, onRowSelected, - onUpdateColumns, onUnPinEvent, pinnedEventIds, refetch, @@ -82,7 +70,6 @@ const EventsComponent: React.FC<Props> = ({ rowRenderers, selectedEventIds, showCheckboxes, - toggleColumn, updateNote, }) => ( <EventsTbody data-test-subj="events"> @@ -93,8 +80,6 @@ const EventsComponent: React.FC<Props> = ({ browserFields={browserFields} columnHeaders={columnHeaders} columnRenderers={columnRenderers} - containerElementRef={containerElementRef} - docValueFields={docValueFields} event={event} eventIdToNoteIds={eventIdToNoteIds} getNotesByIds={getNotesByIds} @@ -106,14 +91,12 @@ const EventsComponent: React.FC<Props> = ({ onPinEvent={onPinEvent} onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} - onUpdateColumns={onUpdateColumns} refetch={refetch} rowRenderers={rowRenderers} onRuleChange={onRuleChange} selectedEventIds={selectedEventIds} showCheckboxes={showCheckboxes} timelineId={id} - toggleColumn={toggleColumn} updateNote={updateNote} /> ))} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 83e824aa2450a6..6c28c0ce16df1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -4,28 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useRef, useState, useCallback } from 'react'; +import React, { useMemo, useRef, useState, useCallback } from 'react'; import uuid from 'uuid'; +import { useDispatch } from 'react-redux'; -import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; -import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; -import { useTimelineEventsDetails } from '../../../../containers/details'; +import { TimelineId } from '../../../../../../common/types/timeline'; +import { BrowserFields } from '../../../../../common/containers/source'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { - TimelineEventsDetailsItem, TimelineItem, TimelineNonEcsData, } from '../../../../../../common/search_strategy/timeline'; import { Note } from '../../../../../common/lib/note'; -import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { - OnColumnResized, - OnPinEvent, - OnRowSelected, - OnUnPinEvent, - OnUpdateColumns, -} from '../../events'; -import { ExpandableEvent } from '../../expandable_event'; +import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles'; import { ColumnRenderer } from '../renderers/column_renderer'; @@ -36,17 +29,15 @@ import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import { inputsModel } from '../../../../../common/store'; -import { TimelineId } from '../../../../../../common/types/timeline'; +import { timelineActions } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; interface Props { actionsColumnWidth: number; - containerElementRef: HTMLDivElement; addNoteToEvent: AddNoteToEvent; browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; - docValueFields: DocValueFields[]; event: TimelineItem; eventIdToNoteIds: Readonly<Record<string, string[]>>; getNotesByIds: (noteIds: string[]) => Note[]; @@ -56,7 +47,6 @@ interface Props { onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; - onUpdateColumns: OnUpdateColumns; isEventPinned: boolean; refetch: inputsModel.Refetch; onRuleChange?: () => void; @@ -64,14 +54,11 @@ interface Props { selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>; showCheckboxes: boolean; timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; updateNote: UpdateNote; } export const getNewNoteId = (): string => uuid.v4(); -const emptyDetails: TimelineEventsDetailsItem[] = []; - const emptyNotes: string[] = []; const EventsTrSupplementContainerWrapper = React.memo(({ children }) => { @@ -85,10 +72,8 @@ const StatefulEventComponent: React.FC<Props> = ({ actionsColumnWidth, addNoteToEvent, browserFields, - containerElementRef, columnHeaders, columnRenderers, - docValueFields, event, eventIdToNoteIds, getNotesByIds, @@ -99,43 +84,50 @@ const StatefulEventComponent: React.FC<Props> = ({ onPinEvent, onRowSelected, onUnPinEvent, - onUpdateColumns, refetch, onRuleChange, rowRenderers, selectedEventIds, showCheckboxes, timelineId, - toggleColumn, updateNote, }) => { - const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>( - timelineId === TimelineId.active ? activeTimeline.getExpandedEventIds() : {} - ); + const dispatch = useDispatch(); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - const { status: timelineStatus } = useShallowEqualSelector<TimelineModel>( + const { expandedEvent, status: timelineStatus } = useDeepEqualSelector( (state) => state.timeline.timelineById[timelineId] ); const divElement = useRef<HTMLDivElement | null>(null); - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: event._index!, - eventId: event._id, - skip: !expanded || !expanded[event._id], - }); + + const isExpanded = useMemo(() => expandedEvent && expandedEvent.eventId === event._id, [ + event._id, + expandedEvent, + ]); const onToggleShowNotes = useCallback(() => { const eventId = event._id; setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] })); }, [event]); - const onToggleExpanded = useCallback(() => { + const handleOnEventToggled = useCallback(() => { const eventId = event._id; - setExpanded((prevExpanded) => ({ ...prevExpanded, [eventId]: !prevExpanded[eventId] })); + const indexName = event._index!; + + dispatch( + timelineActions.toggleExpandedEvent({ + timelineId, + event: { + eventId, + indexName, + loading: false, + }, + }) + ); + if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent(eventId); + activeTimeline.toggleExpandedEvent({ eventId, indexName, loading: false }); } - }, [event._id, timelineId]); + }, [dispatch, event._id, event._index, timelineId]); const associateNote = useCallback( (noteId: string) => { @@ -153,6 +145,7 @@ const StatefulEventComponent: React.FC<Props> = ({ data-test-subj="event" eventType={getEventType(event.ecs)} isBuildingBlockType={isEventBuildingBlockType(event.ecs)} + isExpanded={isExpanded} showLeftBorder={!isEventViewer} ref={divElement} > @@ -164,15 +157,14 @@ const StatefulEventComponent: React.FC<Props> = ({ columnRenderers={columnRenderers} data={event.data} ecsData={event.ecs} - expanded={!!expanded[event._id]} eventIdToNoteIds={eventIdToNoteIds} + expanded={isExpanded} getNotesByIds={getNotesByIds} isEventPinned={isEventPinned} isEventViewer={isEventViewer} - loading={loading} loadingEventIds={loadingEventIds} onColumnResized={onColumnResized} - onEventToggled={onToggleExpanded} + onEventToggled={handleOnEventToggled} onPinEvent={onPinEvent} onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} @@ -209,23 +201,6 @@ const StatefulEventComponent: React.FC<Props> = ({ data: event.ecs, timelineId, })} - - <EventsTrSupplement - className="siemEventsTable__trSupplement--attributes" - data-test-subj="event-details" - > - <ExpandableEvent - browserFields={browserFields} - columnHeaders={columnHeaders} - event={detailsData || emptyDetails} - forceExpand={!!expanded[event._id] && !loading} - id={event._id} - onEventToggled={onToggleExpanded} - onUpdateColumns={onUpdateColumns} - timelineId={timelineId} - toggleColumn={toggleColumn} - /> - </EventsTrSupplement> </EventsTrSupplementContainerWrapper> </EventsTrGroup> ); 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 8fa5d18c0c4f50..99dfd53145e9f3 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 @@ -18,7 +18,6 @@ import { Sort } from './sort'; import { waitFor } from '@testing-library/react'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { SELECTOR_TIMELINE_BODY_CLASS_NAME, TimelineBody } from '../styles'; -import { TimelineType } from '../../../../../common/types/timeline'; const mockGetNotesByIds = (eventId: string[]) => []; const mockSort: Sort = { @@ -28,6 +27,7 @@ const mockSort: Sort = { jest.mock('../../../../common/hooks/use_selector', () => ({ useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), + useDeepEqualSelector: jest.fn().mockReturnValue(mockTimelineModel), })); jest.mock('../../../../common/components/link_to'); @@ -77,7 +77,6 @@ describe('Body', () => { sort: mockSort, showCheckboxes: false, timelineId: 'timeline-test', - timelineType: TimelineType.default, toggleColumn: jest.fn(), updateNote: jest.fn(), }; 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 e1667ab949732d..05a66c6853f6cc 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 @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useRef } from 'react'; +import React, { useMemo } from 'react'; import { inputsModel } from '../../../../common/store'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; import { Note } from '../../../../common/lib/note'; -import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions } from '../../../store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; import { OnColumnRemoved, @@ -29,9 +29,8 @@ import { Events } from './events'; import { ColumnRenderer } from './renderers/column_renderer'; import { RowRenderer } from './renderers/row_renderer'; import { Sort } from './sort'; -import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; -import { TimelineEventsType, TimelineId, TimelineType } from '../../../../../common/types/timeline'; +import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -64,7 +63,6 @@ export interface BodyProps { showCheckboxes: boolean; sort: Sort; timelineId: string; - timelineType: TimelineType; toggleColumn: (column: ColumnHeaderOptions) => void; updateNote: UpdateNote; } @@ -84,7 +82,6 @@ export const Body = React.memo<BodyProps>( columnHeaders, columnRenderers, data, - docValueFields, eventIdToNoteIds, getNotesByIds, graphEventId, @@ -109,10 +106,8 @@ export const Body = React.memo<BodyProps>( sort, toggleColumn, timelineId, - timelineType, updateNote, }) => { - const containerElementRef = useRef<HTMLDivElement>(null); const actionsColumnWidth = useMemo( () => getActionsColumnWidth( @@ -133,18 +128,9 @@ export const Body = React.memo<BodyProps>( return ( <> - {graphEventId && ( - <GraphOverlay - graphEventId={graphEventId} - isEventViewer={isEventViewer} - timelineId={timelineId} - timelineType={timelineType} - /> - )} <TimelineBody data-test-subj="timeline-body" data-timeline-id={timelineId} - ref={containerElementRef} visible={show && !graphEventId} > <EventsTable data-test-subj="events-table" columnWidths={columnWidths}> @@ -167,14 +153,12 @@ export const Body = React.memo<BodyProps>( /> <Events - containerElementRef={containerElementRef.current!} actionsColumnWidth={actionsColumnWidth} addNoteToEvent={addNoteToEvent} browserFields={browserFields} columnHeaders={columnHeaders} columnRenderers={columnRenderers} data={data} - docValueFields={docValueFields} eventIdToNoteIds={eventIdToNoteIds} getNotesByIds={getNotesByIds} id={timelineId} @@ -183,7 +167,6 @@ export const Body = React.memo<BodyProps>( onColumnResized={onColumnResized} onPinEvent={onPinEvent} onRowSelected={onRowSelected} - onUpdateColumns={onUpdateColumns} onUnPinEvent={onUnPinEvent} pinnedEventIds={pinnedEventIds} refetch={refetch} @@ -201,4 +184,5 @@ export const Body = React.memo<BodyProps>( ); } ); + Body.displayName = 'Body'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx index d7a05e39e76b2f..120b3ce165909d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx @@ -80,7 +80,6 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>( graphEventId, refetch, sort, - timelineType, toggleColumn, unPinEvent, updateColumns, @@ -220,7 +219,6 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>( showCheckboxes={showCheckboxes} sort={sort} timelineId={id} - timelineType={timelineType} toggleColumn={toggleColumn} updateNote={onUpdateNote} /> @@ -243,8 +241,7 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>( prevProps.show === nextProps.show && prevProps.selectedEventIds === nextProps.selectedEventIds && prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.sort === nextProps.sort && - prevProps.timelineType === nextProps.timelineType + prevProps.sort === nextProps.sort ); StatefulBodyComponent.displayName = 'StatefulBodyComponent'; @@ -270,7 +267,6 @@ const makeMapStateToProps = () => { selectedEventIds, show, showCheckboxes, - timelineType, } = timeline; return { @@ -286,7 +282,6 @@ const makeMapStateToProps = () => { selectedEventIds, show, showCheckboxes, - timelineType, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx new file mode 100644 index 00000000000000..4b595fad9be6f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import deepEqual from 'fast-deep-equal'; + +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; +import { BrowserFields, DocValueFields } from '../../../common/containers/source'; +import { + ExpandableEvent, + ExpandableEventTitle, +} from '../../../timelines/components/timeline/expandable_event'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; + +interface EventDetailsProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + timelineId: string; + toggleColumn: (column: ColumnHeaderOptions) => void; +} + +const EventDetailsComponent: React.FC<EventDetailsProps> = ({ + browserFields, + docValueFields, + timelineId, + toggleColumn, +}) => { + const expandedEvent = useDeepEqualSelector( + (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? {} + ); + + return ( + <> + <ExpandableEventTitle /> + <EuiSpacer /> + <ExpandableEvent + browserFields={browserFields} + docValueFields={docValueFields} + event={expandedEvent} + timelineId={timelineId} + toggleColumn={toggleColumn} + /> + </> + ); +}; + +export const EventDetails = React.memo( + EventDetailsComponent, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.docValueFields, nextProps.docValueFields) && + prevProps.timelineId === nextProps.timelineId && + prevProps.toggleColumn === nextProps.toggleColumn +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index b1f48608346c78..77aee2c4bf0126 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -4,62 +4,77 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiTextColor, EuiLoadingContent, EuiTitle } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { BrowserFields } from '../../../../common/containers/source'; +import { TimelineExpandedEvent } from '../../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; -import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import { StatefulEventDetails } from '../../../../common/components/event_details/stateful_event_details'; import { LazyAccordion } from '../../lazy_accordion'; -import { OnUpdateColumns } from '../events'; +import { useTimelineEventsDetails } from '../../../containers/details'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { getColumnHeaders } from '../body/column_headers/helpers'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import * as i18n from './translations'; -const ExpandableDetails = styled.div<{ hideExpandButton: boolean }>` - ${({ hideExpandButton }) => - hideExpandButton - ? ` +const ExpandableDetails = styled.div` .euiAccordion__button { display: none; } - ` - : ''}; `; ExpandableDetails.displayName = 'ExpandableDetails'; interface Props { browserFields: BrowserFields; - columnHeaders: ColumnHeaderOptions[]; - id: string; - event: TimelineEventsDetailsItem[]; - forceExpand?: boolean; - hideExpandButton?: boolean; - onEventToggled: () => void; - onUpdateColumns: OnUpdateColumns; + docValueFields: DocValueFields[]; + event: TimelineExpandedEvent; timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; } +export const ExpandableEventTitle = React.memo(() => ( + <EuiTitle size="s"> + <h4>{i18n.EVENT_DETAILS}</h4> + </EuiTitle> +)); + +ExpandableEventTitle.displayName = 'ExpandableEventTitle'; + export const ExpandableEvent = React.memo<Props>( - ({ - browserFields, - columnHeaders, - event, - forceExpand = false, - id, - timelineId, - toggleColumn, - onEventToggled, - onUpdateColumns, - }) => { + ({ browserFields, docValueFields, event, timelineId, toggleColumn }) => { + const dispatch = useDispatch(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + + const columnHeaders = useDeepEqualSelector((state) => { + const { columns } = getTimeline(state, timelineId) ?? timelineDefaults; + + return getColumnHeaders(columns, browserFields); + }); + + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: event.indexName!, + eventId: event.eventId!, + skip: !event.eventId, + }); + + const onUpdateColumns = useCallback( + (columns) => dispatch(timelineActions.updateColumns({ id: timelineId, columns })), + [dispatch, timelineId] + ); + const handleRenderExpandedContent = useCallback( () => ( <StatefulEventDetails browserFields={browserFields} columnHeaders={columnHeaders} - data={event} - id={id} - onEventToggled={onEventToggled} + data={detailsData!} + id={event.eventId!} onUpdateColumns={onUpdateColumns} timelineId={timelineId} toggleColumn={toggleColumn} @@ -68,21 +83,28 @@ export const ExpandableEvent = React.memo<Props>( [ browserFields, columnHeaders, - event, - id, - onEventToggled, + detailsData, + event.eventId, onUpdateColumns, timelineId, toggleColumn, ] ); + if (!event.eventId) { + return <EuiTextColor color="subdued">{i18n.EVENT_DETAILS_PLACEHOLDER}</EuiTextColor>; + } + + if (loading) { + return <EuiLoadingContent lines={10} />; + } + return ( - <ExpandableDetails hideExpandButton={true}> + <ExpandableDetails> <LazyAccordion - id={`timeline-${timelineId}-row-${id}`} + id={`timeline-${timelineId}-row-${event.eventId}`} renderExpandedContent={handleRenderExpandedContent} - forceExpand={forceExpand} + forceExpand={!!event.eventId && !loading} paddingSize="none" /> </ExpandableDetails> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx index 19b360b24391de..a4c4679c820580 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx @@ -19,3 +19,17 @@ export const EVENT = i18n.translate( defaultMessage: 'Event', } ); + +export const EVENT_DETAILS_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.timeline.expandableEvent.placeholder', + { + defaultMessage: 'Select an event to show its details', + } +); + +export const EVENT_DETAILS = i18n.translate( + 'xpack.securitySolution.timeline.expandableEvent.titleLabel', + { + defaultMessage: 'Event details', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 35d31e034e7f38..baa62b629567da 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,6 +18,7 @@ import { OnChangeItemsPerPage } from './events'; import { Timeline } from './timeline'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { activeTimeline } from '../../containers/active_timeline_context'; export interface OwnProps { id: string; @@ -98,7 +99,13 @@ const StatefulTimelineComponent = React.memo<Props>( useEffect(() => { if (createTimeline != null && !isTimelineExists) { - createTimeline({ id, columns: defaultHeaders, indexNames: selectedPatterns, show: false }); + createTimeline({ + id, + columns: defaultHeaders, + indexNames: selectedPatterns, + show: false, + expandedEvent: activeTimeline.getExpandedEvent(), + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -226,7 +233,6 @@ const mapDispatchToProps = { createTimeline: timelineActions.createTimeline, removeColumn: timelineActions.removeColumn, updateColumns: timelineActions.updateColumns, - updateHighlightedDropAndProviderId: timelineActions.updateHighlightedDropAndProviderId, updateItemsPerPage: timelineActions.updateItemsPerPage, updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, updateSort: timelineActions.updateSort, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 5e0d15f3bfbc3b..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SkeletonRow it renders 1`] = ` -<Row> - <Cell - key="0" - /> - <Cell - key="1" - /> - <Cell - key="2" - /> - <Cell - key="3" - /> -</Row> -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.test.tsx deleted file mode 100644 index b63359077bf2c9..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../common/mock'; -import { SkeletonRow } from './index'; - -describe('SkeletonRow', () => { - test('it renders', () => { - const wrapper = shallow(<SkeletonRow />); - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the correct number of cells if cellCount is specified', () => { - const wrapper = mount( - <TestProviders> - <SkeletonRow cellCount={10} /> - </TestProviders> - ); - - expect(wrapper.find('.siemSkeletonRow__cell')).toHaveLength(10); - }); - - test('it applies row and cell styles when cellColor/cellMargin/rowHeight/rowPadding provided', () => { - const wrapper = mount( - <TestProviders> - <SkeletonRow cellColor="red" cellMargin="10px" rowHeight="100px" rowPadding="10px" /> - </TestProviders> - ); - const siemSkeletonRow = wrapper.find('.siemSkeletonRow').first(); - const siemSkeletonRowCell = wrapper.find('.siemSkeletonRow__cell').last(); - - expect(siemSkeletonRow).toHaveStyleRule('height', '100px'); - expect(siemSkeletonRow).toHaveStyleRule('padding', '10px'); - expect(siemSkeletonRowCell).toHaveStyleRule('background-color', 'red'); - expect(siemSkeletonRowCell).toHaveStyleRule('margin-left', '10px', { - modifier: '& + &', - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.tsx deleted file mode 100644 index ae30f11d8bb168..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/skeleton_row/index.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useMemo } from 'react'; -import styled from 'styled-components'; - -interface RowProps { - rowHeight?: string; - rowPadding?: string; -} - -const RowComponent = styled.div.attrs<RowProps>(({ rowHeight, rowPadding, theme }) => ({ - className: 'siemSkeletonRow', - rowHeight: rowHeight || theme.eui.euiSizeXL, - rowPadding: rowPadding || `${theme.eui.paddingSizes.s} ${theme.eui.paddingSizes.xs}`, -}))<RowProps>` - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - display: flex; - height: ${({ rowHeight }) => rowHeight}; - padding: ${({ rowPadding }) => rowPadding}; -`; -RowComponent.displayName = 'RowComponent'; - -const Row = React.memo(RowComponent); - -Row.displayName = 'Row'; - -interface CellProps { - cellColor?: string; - cellMargin?: string; -} - -const CellComponent = styled.div.attrs<CellProps>(({ cellColor, cellMargin, theme }) => ({ - className: 'siemSkeletonRow__cell', - cellColor: cellColor || theme.eui.euiColorLightestShade, - cellMargin: cellMargin || theme.eui.gutterTypes.gutterSmall, -}))<CellProps>` - background-color: ${({ cellColor }) => cellColor}; - border-radius: 2px; - flex: 1; - - & + & { - margin-left: ${({ cellMargin }) => cellMargin}; - } -`; -CellComponent.displayName = 'CellComponent'; - -const Cell = React.memo(CellComponent); - -Cell.displayName = 'Cell'; - -export interface SkeletonRowProps extends CellProps, RowProps { - cellCount?: number; -} - -export const SkeletonRow = React.memo<SkeletonRowProps>( - ({ cellColor, cellCount = 4, cellMargin, rowHeight, rowPadding }) => { - const cells = useMemo( - () => - [...Array(cellCount)].map( - (_, i) => <Cell cellColor={cellColor} cellMargin={cellMargin} key={i} />, - [cellCount] - ), - [cellCount, cellColor, cellMargin] - ); - - return ( - <Row rowHeight={rowHeight} rowPadding={rowPadding}> - {cells} - </Row> - ); - } -); -SkeletonRow.displayName = 'SkeletonRow'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index d146818e7ab907..e4c49ce197c2a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -176,17 +176,18 @@ export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({ }))<{ className?: string; eventType: Omit<TimelineEventsType, 'all'>; + isExpanded: boolean; isBuildingBlockType: boolean; showLeftBorder: boolean; }>` border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid ${({ theme }) => theme.eui.euiColorLightShade}; - ${({ theme, eventType, isBuildingBlockType, showLeftBorder }) => + ${({ theme, eventType, showLeftBorder }) => showLeftBorder ? `border-left: 4px solid ${eventType === 'raw' ? theme.eui.euiColorLightShade : theme.eui.euiColorWarning}` : ''}; - ${({ isBuildingBlockType, showLeftBorder }) => + ${({ isBuildingBlockType }) => isBuildingBlockType ? `background: repeating-linear-gradient(127deg, rgba(245, 167, 0, 0.2), rgba(245, 167, 0, 0.2) 1px, rgba(245, 167, 0, 0.05) 2px, rgba(245, 167, 0, 0.05) 10px);` : ''}; @@ -194,6 +195,16 @@ export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({ &:hover { background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; } + + ${({ isExpanded, theme }) => + isExpanded && + ` + background: ${theme.eui.euiTableSelectedColor}; + + &:hover { + ${theme.eui.euiTableHoverSelectedColor} + } + `} `; export const EventsTrData = styled.div.attrs(({ className = '' }) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 7fc269c954ac40..900699503a3bb7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -214,19 +214,5 @@ describe('Timeline', () => { expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(true); }); - describe('when there is a graphEventId', () => { - beforeEach(() => { - props.graphEventId = 'graphEventId'; // any string w/ length > 0 works - }); - it('should not show the timeline footer', () => { - const wrapper = mount( - <TestProviders> - <TimelineComponent {...props} /> - </TestProviders> - ); - - expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(false); - }); - }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index f7c76c110ac3f1..d5148eeb3655f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, EuiProgress } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiProgress, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect } from 'react'; import styled from 'styled-components'; @@ -35,6 +42,8 @@ import { import { useManageTimeline } from '../manage_timeline'; import { TimelineType, TimelineStatusLiteral } from '../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../detections/components/alerts_table/default_config'; +import { GraphOverlay } from '../graph_overlay'; +import { EventDetails } from './event_details'; const TimelineContainer = styled.div` height: 100%; @@ -79,6 +88,16 @@ const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` padding: 0 10px 5px 12px; `; +const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` + width: 100%; + overflow: hidden; + display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: auto; +`; + const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; color: #fff; @@ -86,6 +105,12 @@ const TimelineTemplateBadge = styled.div` font-size: 0.8em; `; +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + export interface Props { browserFields: BrowserFields; columns: ColumnHeaderOptions[]; @@ -261,20 +286,30 @@ export const TimelineComponent: React.FC<Props> = ({ loading={loading} refetch={refetch} /> - <StyledEuiFlyoutBody data-test-subj="eui-flyout-body" className="timeline-flyout-body"> - <StatefulBody - browserFields={browserFields} - data={events} - docValueFields={docValueFields} - id={id} - refetch={refetch} - sort={sort} - toggleColumn={toggleColumn} + {graphEventId && ( + <GraphOverlay + graphEventId={graphEventId} + isEventViewer={false} + timelineId={id} + timelineType={timelineType} /> - </StyledEuiFlyoutBody> - { - /** Hide the footer if Resolver is showing. */ - !graphEventId && ( + )} + <FullWidthFlexGroup $visible={!graphEventId}> + <ScrollableFlexItem grow={2}> + <StyledEuiFlyoutBody + data-test-subj="eui-flyout-body" + className="timeline-flyout-body" + > + <StatefulBody + browserFields={browserFields} + data={events} + docValueFields={docValueFields} + id={id} + refetch={refetch} + sort={sort} + toggleColumn={toggleColumn} + /> + </StyledEuiFlyoutBody> <StyledEuiFlyoutFooter data-test-subj="eui-flyout-footer" className="timeline-flyout-footer" @@ -295,8 +330,17 @@ export const TimelineComponent: React.FC<Props> = ({ totalCount={totalCount} /> </StyledEuiFlyoutFooter> - ) - } + </ScrollableFlexItem> + <VerticalRule /> + <ScrollableFlexItem grow={1}> + <EventDetails + browserFields={browserFields} + docValueFields={docValueFields} + timelineId={id} + toggleColumn={toggleColumn} + /> + </ScrollableFlexItem> + </FullWidthFlexGroup> </> ) : null} </TimelineContainer> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts index 50bf8b37adf28d..287fcd7f11e93d 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimelineArgs } from '.'; +import { TimelineExpandedEvent } from '../../../common/types/timeline'; import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; +import { TimelineArgs } from '.'; /* * Future Engineer @@ -17,9 +18,10 @@ import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy * I did not want to put in the store because I was feeling it will feel less temporarily and I did not want other engineer using it * */ + class ActiveTimelineEvents { private _activePage: number = 0; - private _expandedEventIds: Record<string, boolean> = {}; + private _expandedEvent: TimelineExpandedEvent = {}; private _pageName: string = ''; private _request: TimelineEventsAllRequestOptions | null = null; private _response: TimelineArgs | null = null; @@ -32,19 +34,20 @@ class ActiveTimelineEvents { this._activePage = activePage; } - getExpandedEventIds() { - return this._expandedEventIds; + getExpandedEvent() { + return this._expandedEvent; } - toggleExpandedEvent(eventId: string) { - this._expandedEventIds = { - ...this._expandedEventIds, - [eventId]: !this._expandedEventIds[eventId], - }; + toggleExpandedEvent(expandedEvent: TimelineExpandedEvent) { + if (expandedEvent.eventId === this._expandedEvent.eventId) { + this._expandedEvent = {}; + } else { + this._expandedEvent = expandedEvent; + } } - setExpandedEventIds(expandedEventIds: Record<string, boolean>) { - this._expandedEventIds = expandedEventIds; + setExpandedEvent(expandedEvent: TimelineExpandedEvent) { + this._expandedEvent = expandedEvent; } getPageName() { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 5f92596f033114..2465d0a5364823 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -136,7 +136,7 @@ export const useTimelineEvents = ({ clearSignalsState(); if (id === TimelineId.active) { - activeTimeline.setExpandedEventIds({}); + activeTimeline.setExpandedEvent({}); activeTimeline.setActivePage(newActivePage); } @@ -200,7 +200,7 @@ export const useTimelineEvents = ({ updatedAt: Date.now(), }; if (id === TimelineId.active) { - activeTimeline.setExpandedEventIds({}); + activeTimeline.setExpandedEvent({}); activeTimeline.setPageName(pageName); activeTimeline.setRequest(request); activeTimeline.setResponse(newTimelineResponse); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index c066de8af9f209..c2fff49afdcbf7 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -19,6 +19,7 @@ import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, + TimelineExpandedEvent, TimelineTypeLiteral, RowRendererId, } from '../../../../common/types/timeline'; @@ -34,6 +35,12 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); +interface ToggleExpandedEvent { + timelineId: string; + event: TimelineExpandedEvent; +} +export const toggleExpandedEvent = actionCreator<ToggleExpandedEvent>('TOGGLE_EXPANDED_EVENT'); + export const upsertColumn = actionCreator<{ column: ColumnHeaderOptions; id: string; @@ -42,14 +49,6 @@ export const upsertColumn = actionCreator<{ export const addProvider = actionCreator<{ id: string; provider: DataProvider }>('ADD_PROVIDER'); -export const applyDeltaToWidth = actionCreator<{ - id: string; - delta: number; - bodyClientWidthPixels: number; - minWidthPixels: number; - maxWidthPercent: number; -}>('APPLY_DELTA_TO_WIDTH'); - export const applyDeltaToColumnWidth = actionCreator<{ id: string; columnId: string; @@ -64,6 +63,7 @@ export interface TimelineInput { end: string; }; excludedRowRendererIds?: RowRendererId[]; + expandedEvent?: TimelineExpandedEvent; filters?: Filter[]; columns: ColumnHeaderOptions[]; itemsPerPage?: number; @@ -173,11 +173,6 @@ export const updateDataProviderType = actionCreator<{ providerId: string; }>('UPDATE_PROVIDER_TYPE'); -export const updateHighlightedDropAndProviderId = actionCreator<{ - id: string; - providerId: string; -}>('UPDATE_DROP_AND_PROVIDER'); - export const updateDescription = actionCreator<{ id: string; description: string; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index ce469c2bf57a28..39174c9092af57 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -7,7 +7,6 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; import { Direction } from '../../../graphql/types'; -import { DEFAULT_TIMELINE_WIDTH } from '../../components/timeline/body/constants'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { SubsetTimelineModel, TimelineModel } from './model'; @@ -24,6 +23,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter eventType: 'all', eventIdToNoteIds: {}, excludedRowRendererIds: [], + expandedEvent: {}, highlightedDropAndProviderId: '', historyIds: [], filters: [], @@ -57,6 +57,5 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter sortDirection: Direction.desc, }, status: TimelineStatus.draft, - width: DEFAULT_TIMELINE_WIDTH, version: null, }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index 92a913c9c33758..78e30bd81817c2 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -89,6 +89,7 @@ describe('Epic Timeline', () => { description: '', eventIdToNoteIds: {}, eventType: 'all', + expandedEvent: {}, excludedRowRendererIds: [], highlightedDropAndProviderId: '', historyIds: [], @@ -150,7 +151,6 @@ describe('Epic Timeline', () => { showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc }, status: TimelineStatus.active, - width: 1100, version: 'WzM4LDFd', id: '11169110-fc22-11e9-8ca9-072f15ce2685', savedQueryId: 'my endgame timeline query', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 9a0bf5ec4a940c..241b8c5030de79 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -23,6 +23,7 @@ import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/m import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, + TimelineExpandedEvent, TimelineTypeLiteral, TimelineType, RowRendererId, @@ -142,7 +143,7 @@ export const addTimelineToStore = ({ }: AddTimelineParams): TimelineById => { if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { activeTimeline.setActivePage(0); - activeTimeline.setExpandedEventIds({}); + activeTimeline.setExpandedEvent({}); } return { ...timelineById, @@ -169,6 +170,7 @@ interface AddNewTimelineParams { end: string; }; excludedRowRendererIds?: RowRendererId[]; + expandedEvent?: TimelineExpandedEvent; filters?: Filter[]; id: string; itemsPerPage?: number; @@ -190,6 +192,7 @@ export const addNewTimeline = ({ dataProviders = [], dateRange: maybeDateRange, excludedRowRendererIds = [], + expandedEvent = {}, filters = timelineDefaults.filters, id, itemsPerPage = timelineDefaults.itemsPerPage, @@ -218,6 +221,7 @@ export const addNewTimeline = ({ columns, dataProviders, dateRange, + expandedEvent, excludedRowRendererIds, filters, itemsPerPage, @@ -303,39 +307,6 @@ export const updateGraphEventId = ({ }; }; -interface ApplyDeltaToCurrentWidthParams { - id: string; - delta: number; - bodyClientWidthPixels: number; - minWidthPixels: number; - maxWidthPercent: number; - timelineById: TimelineById; -} - -export const applyDeltaToCurrentWidth = ({ - id, - delta, - bodyClientWidthPixels, - minWidthPixels, - maxWidthPercent, - timelineById, -}: ApplyDeltaToCurrentWidthParams): TimelineById => { - const timeline = timelineById[id]; - - const requestedWidth = timeline.width + delta * -1; // raw change in width - const maxWidthPixels = (maxWidthPercent / 100) * bodyClientWidthPixels; - const clampedWidth = Math.min(requestedWidth, maxWidthPixels); - const width = Math.max(minWidthPixels, clampedWidth); // if the clamped width is smaller than the min, use the min - - return { - ...timelineById, - [id]: { - ...timeline, - width, - }, - }; -}; - const queryMatchCustomizer = (dp1: QueryMatch, dp2: QueryMatch) => { if (dp1.field === dp2.field && dp1.value === dp2.value && dp1.operator === dp2.operator) { return true; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index ec4d37d3b70a25..7d015c1dc82b14 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -13,6 +13,7 @@ import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline' import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/types'; import type { TimelineEventsType, + TimelineExpandedEvent, TimelineType, TimelineStatus, RowRendererId, @@ -57,6 +58,7 @@ export interface TimelineModel { eventIdToNoteIds: Record<string, string[]>; /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; + expandedEvent: TimelineExpandedEvent; filters?: Filter[]; /** When non-empty, display a graph view for this event */ graphEventId?: string; @@ -117,8 +119,6 @@ export interface TimelineModel { sort: Sort; /** status: active | draft */ status: TimelineStatus; - /** Persists the UI state (width) of the timeline flyover */ - width: number; /** timeline is saving */ isSaving: boolean; isLoading: boolean; @@ -135,6 +135,7 @@ export type SubsetTimelineModel = Readonly< | 'eventType' | 'eventIdToNoteIds' | 'excludedRowRendererIds' + | 'expandedEvent' | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' @@ -159,7 +160,6 @@ export type SubsetTimelineModel = Readonly< | 'show' | 'showCheckboxes' | 'sort' - | 'width' | 'isSaving' | 'isLoading' | 'savedObjectId' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 7bd86cd7e24527..cd89c9df7e3db5 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -14,10 +14,7 @@ import { DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_TIMELINE_WIDTH, -} from '../../../timelines/components/timeline/body/constants'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Direction } from '../../../graphql/types'; import { defaultHeaders } from '../../../common/mock'; @@ -81,6 +78,7 @@ const basicTimeline: TimelineModel = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], + expandedEvent: {}, highlightedDropAndProviderId: '', historyIds: [], id: 'foo', @@ -112,7 +110,6 @@ const basicTimeline: TimelineModel = { timelineType: TimelineType.default, title: '', version: null, - width: DEFAULT_TIMELINE_WIDTH, }; const timelineByIdMock: TimelineById = { foo: { ...basicTimeline }, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 7c227f1c806101..3f2b56b3f7dba9 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -12,7 +12,6 @@ import { addProvider, addTimeline, applyDeltaToColumnWidth, - applyDeltaToWidth, applyKqlFilterQuery, clearEventsDeleted, clearEventsLoading, @@ -34,6 +33,7 @@ import { showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, + toggleExpandedEvent, unPinEvent, updateAutoSaveMsg, updateColumns, @@ -43,7 +43,6 @@ import { updateDataProviderType, updateDescription, updateEventType, - updateHighlightedDropAndProviderId, updateIndexNames, updateIsFavorite, updateIsLive, @@ -67,7 +66,6 @@ import { addTimelineNoteToEvent, addTimelineProvider, addTimelineToStore, - applyDeltaToCurrentWidth, applyDeltaToTimelineColumnWidth, applyKqlFilterQueryDraft, pinTimelineEvent, @@ -78,7 +76,6 @@ import { setSelectedTimelineEvents, unPinTimelineEvent, updateExcludedRowRenderersIds, - updateHighlightedDropAndProvider, updateKqlFilterQueryDraft, updateTimelineColumns, updateTimelineDescription, @@ -181,6 +178,16 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) + .case(toggleExpandedEvent, (state, { timelineId, event }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [timelineId]: { + ...state.timelineById[timelineId], + expandedEvent: event, + }, + }, + })) .case(addProvider, (state, { id, provider }) => ({ ...state, timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }), @@ -218,20 +225,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case( - applyDeltaToWidth, - (state, { id, delta, bodyClientWidthPixels, minWidthPixels, maxWidthPercent }) => ({ - ...state, - timelineById: applyDeltaToCurrentWidth({ - id, - delta, - bodyClientWidthPixels, - minWidthPixels, - maxWidthPercent, - timelineById: state.timelineById, - }), - }) - ) .case(pinEvent, (state, { id, eventId }) => ({ ...state, timelineById: pinTimelineEvent({ id, eventId, timelineById: state.timelineById }), @@ -485,14 +478,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(updateHighlightedDropAndProviderId, (state, { id, providerId }) => ({ - ...state, - timelineById: updateHighlightedDropAndProvider({ - id, - providerId, - timelineById: state.timelineById, - }), - })) .case(updateAutoSaveMsg, (state, { timelineId, newTimelineModel }) => ({ ...state, autoSavedWarningMsg: { diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 11964ab4d7b283..58e2ea6111a38e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -10,7 +10,13 @@ import { SavedObjectsClientContract, } from 'src/core/server'; import { SecurityPluginSetup } from '../../../security/server'; -import { AgentService, FleetStartContract, PackageService } from '../../../fleet/server'; +import { + AgentService, + FleetStartContract, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; import { PluginStartContract as AlertsPluginStartContract } from '../../../alerts/server'; import { getPackagePolicyCreateCallback } from './ingest_integration'; import { ManifestManager } from './services/artifacts'; @@ -66,7 +72,10 @@ export const createMetadataService = (packageService: PackageService): MetadataS }; export type EndpointAppContextServiceStartContract = Partial< - Pick<FleetStartContract, 'agentService' | 'packageService'> + Pick< + FleetStartContract, + 'agentService' | 'packageService' | 'packagePolicyService' | 'agentPolicyService' + > > & { logger: Logger; manifestManager?: ManifestManager; @@ -85,11 +94,15 @@ export type EndpointAppContextServiceStartContract = Partial< export class EndpointAppContextService { private agentService: AgentService | undefined; private manifestManager: ManifestManager | undefined; + private packagePolicyService: PackagePolicyServiceInterface | undefined; + private agentPolicyService: AgentPolicyServiceInterface | undefined; private savedObjectsStart: SavedObjectsServiceStart | undefined; private metadataService: MetadataService | undefined; public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; this.manifestManager = dependencies.manifestManager; this.savedObjectsStart = dependencies.savedObjectsStart; this.metadataService = createMetadataService(dependencies.packageService!); @@ -115,6 +128,14 @@ export class EndpointAppContextService { return this.agentService; } + public getPackagePolicyService(): PackagePolicyServiceInterface | undefined { + return this.packagePolicyService; + } + + public getAgentPolicyService(): AgentPolicyServiceInterface | undefined { + return this.agentPolicyService; + } + public getMetadataService(): MetadataService | undefined { return this.metadataService; } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 7a1a0f06a22678..1268c8a4bc576d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -9,13 +9,12 @@ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mock import { securityMock } from '../../../security/server/mocks'; import { alertsMock } from '../../../alerts/server/mocks'; import { xpackMocks } from '../../../../mocks'; +import { FleetStartContract, ExternalCallback, PackageService } from '../../../fleet/server'; import { - AgentService, - FleetStartContract, - ExternalCallback, - PackageService, -} from '../../../fleet/server'; -import { createPackagePolicyServiceMock } from '../../../fleet/server/mocks'; + createPackagePolicyServiceMock, + createMockAgentPolicyService, + createMockAgentService, +} from '../../../fleet/server/mocks'; import { AppClientFactory } from '../client'; import { createMockConfig } from '../lib/detection_engine/routes/__mocks__'; import { @@ -25,6 +24,7 @@ import { import { ManifestManager } from './services/artifacts/manifest_manager/manifest_manager'; import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { EndpointAppContext } from './types'; +import { MetadataRequestContext } from './routes/metadata/handlers'; /** * Creates a mocked EndpointAppContext. @@ -49,6 +49,7 @@ export const createMockEndpointAppContextService = ( start: jest.fn(), stop: jest.fn(), getAgentService: jest.fn(), + getAgentPolicyService: jest.fn(), getManifestManager: jest.fn().mockReturnValue(mockManifestManager ?? jest.fn()), getScopedSavedObjectsClient: jest.fn(), } as unknown) as jest.Mocked<EndpointAppContextService>; @@ -90,18 +91,6 @@ export const createMockPackageService = (): jest.Mocked<PackageService> => { }; }; -/** - * Creates a mock AgentService - */ -export const createMockAgentService = (): jest.Mocked<AgentService> => { - return { - getAgentStatusById: jest.fn(), - authenticateAgentWithAccessToken: jest.fn(), - getAgent: jest.fn(), - listAgents: jest.fn(), - }; -}; - /** * Creates a mock IndexPatternService for use in tests that need to interact with the Ingest Manager's * ESIndexPatternService. @@ -116,11 +105,20 @@ export const createMockFleetStartContract = (indexPattern: string): FleetStartCo }, agentService: createMockAgentService(), packageService: createMockPackageService(), + agentPolicyService: createMockAgentPolicyService(), registerExternalCallback: jest.fn((...args: ExternalCallback) => {}), packagePolicyService: createPackagePolicyServiceMock(), }; }; +export const createMockMetadataRequestContext = (): jest.Mocked<MetadataRequestContext> => { + return { + endpointAppContextService: createMockEndpointAppContextService(), + logger: loggingSystemMock.create().get('mock_endpoint_app_context'), + requestHandlerContext: xpackMocks.createRequestHandlerContext(), + }; +}; + export function createRouteHandlerContext( dataClient: jest.Mocked<ILegacyScopedClusterClient>, savedObjectsClient: jest.Mocked<SavedObjectsClientContract> diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts new file mode 100644 index 00000000000000..5dd668b857229e --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/endpoint/types'; +import { createMockMetadataRequestContext } from '../../mocks'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { enrichHostMetadata, MetadataRequestContext } from './handlers'; + +describe('test document enrichment', () => { + let metaReqCtx: jest.Mocked<MetadataRequestContext>; + const docGen = new EndpointDocGenerator(); + + beforeEach(() => { + metaReqCtx = createMockMetadataRequestContext(); + }); + + // verify query version passed through + describe('metadata query strategy enrichment', () => { + it('should match v1 strategy when directed', async () => { + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_1 + ); + expect(enrichedHostList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); + }); + it('should match v2 strategy when directed', async () => { + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_2 + ); + }); + }); + + describe('host status enrichment', () => { + let statusFn: jest.Mock; + + beforeEach(() => { + statusFn = jest.fn(); + (metaReqCtx.endpointAppContextService.getAgentService as jest.Mock).mockImplementation(() => { + return { + getAgentStatusById: statusFn, + }; + }); + }); + + it('should return host online for online agent', async () => { + statusFn.mockImplementation(() => 'online'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ONLINE); + }); + + it('should return host offline for offline agent', async () => { + statusFn.mockImplementation(() => 'offline'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.OFFLINE); + }); + + it('should return host unenrolling for unenrolling agent', async () => { + statusFn.mockImplementation(() => 'unenrolling'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.UNENROLLING); + }); + + it('should return host error for degraded agent', async () => { + statusFn.mockImplementation(() => 'degraded'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for erroring agent', async () => { + statusFn.mockImplementation(() => 'error'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for warning agent', async () => { + statusFn.mockImplementation(() => 'warning'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for invalid agent', async () => { + statusFn.mockImplementation(() => 'asliduasofb'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + }); + + describe('policy info enrichment', () => { + let agentMock: jest.Mock; + let agentPolicyMock: jest.Mock; + + beforeEach(() => { + agentMock = jest.fn(); + agentPolicyMock = jest.fn(); + (metaReqCtx.endpointAppContextService.getAgentService as jest.Mock).mockImplementation(() => { + return { + getAgent: agentMock, + getAgentStatusById: jest.fn(), + }; + }); + (metaReqCtx.endpointAppContextService.getAgentPolicyService as jest.Mock).mockImplementation( + () => { + return { + get: agentPolicyMock, + }; + } + ); + }); + + it('reflects current applied agent info', async () => { + const policyID = 'abc123'; + const policyRev = 9; + agentMock.mockImplementation(() => { + return { + policy_id: policyID, + policy_revision: policyRev, + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.agent.applied.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.agent.applied.revision).toEqual(policyRev); + }); + + it('reflects current fleet agent info', async () => { + const policyID = 'xyz456'; + const policyRev = 15; + agentPolicyMock.mockImplementation(() => { + return { + id: policyID, + revision: policyRev, + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.agent.configured.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.agent.configured.revision).toEqual(policyRev); + }); + + it('reflects current endpoint policy info', async () => { + const policyID = 'endpoint-b33f'; + const policyRev = 2; + agentPolicyMock.mockImplementation(() => { + return { + package_policies: [ + { + package: { name: 'endpoint' }, + id: policyID, + revision: policyRev, + }, + ], + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.endpoint.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.endpoint.revision).toEqual(policyRev); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index f2011e99565c80..a79175b178c38b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -15,7 +15,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; -import { Agent, AgentStatus } from '../../../../../fleet/common/types/models'; +import { Agent, AgentStatus, PackagePolicy } from '../../../../../fleet/common/types/models'; import { EndpointAppContext, HostListQueryResult } from '../../types'; import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index'; import { findAllUnenrolledAgentIds } from './support/unenroll'; @@ -245,7 +245,7 @@ export async function mapToHostResultList( } } -async function enrichHostMetadata( +export async function enrichHostMetadata( hostMetadata: HostMetadata, metadataRequestContext: MetadataRequestContext, metadataQueryStrategyVersion: MetadataQueryStrategyVersions @@ -282,9 +282,53 @@ async function enrichHostMetadata( throw e; } } + + let policyInfo: HostInfo['policy_info']; + try { + const agent = await metadataRequestContext.endpointAppContextService + ?.getAgentService() + ?.getAgent( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + elasticAgentId + ); + const agentPolicy = await metadataRequestContext.endpointAppContextService + .getAgentPolicyService() + ?.get( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + agent?.policy_id!, + true + ); + const endpointPolicy = ((agentPolicy?.package_policies || []) as PackagePolicy[]).find( + (policy: PackagePolicy) => policy.package?.name === 'endpoint' + ); + + policyInfo = { + agent: { + applied: { + revision: agent?.policy_revision || 0, + id: agent?.policy_id || '', + }, + configured: { + revision: agentPolicy?.revision || 0, + id: agentPolicy?.id || '', + }, + }, + endpoint: { + revision: endpointPolicy?.revision || 0, + id: endpointPolicy?.id || '', + }, + }; + } catch (e) { + // this is a non-vital enrichment of expected policy revisions. + // if we fail just fetching these, the rest of the endpoint + // data should still be returned. log the error and move on + log.error(e); + } + return { metadata: hostMetadata, host_status: hostStatus, + policy_info: policyInfo, query_strategy_version: metadataQueryStrategyVersion, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts index ed3c48ed6c6770..e9a1f1e24fa555 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { findAgentIDsByStatus } from './agent_status'; import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { AgentService } from '../../../../../../fleet/server/services'; -import { createMockAgentService } from '../../../mocks'; +import { createMockAgentService } from '../../../../../../fleet/server/mocks'; import { Agent } from '../../../../../../fleet/common/types/models'; import { AgentStatusKueryHelper } from '../../../../../../fleet/common/services'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts index cd273f785033c8..c88f11422d0f04 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { findAllUnenrolledAgentIds } from './unenroll'; import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { AgentService } from '../../../../../../fleet/server/services'; -import { createMockAgentService } from '../../../mocks'; +import { createMockAgentService } from '../../../../../../fleet/server/mocks'; import { Agent } from '../../../../../../fleet/common/types/models'; describe('test find all unenrolled Agent id', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 009ce043db85ed..0fc3f5135c8f6e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -5,10 +5,10 @@ */ import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { - createMockAgentService, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; +import { createMockAgentService } from '../../../../../fleet/server/mocks'; import { getHostPolicyResponseHandler, getAgentPolicySummaryHandler } from './handlers'; import { ILegacyScopedClusterClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index a704d076880bf3..e50956e9ef7521 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -103,6 +103,14 @@ export const buildSignalGroupFromSequence = ( outputIndex ); + if ( + wrappedBuildingBlocks.some((block) => + block._source.signal?.ancestors.some((ancestor) => ancestor.rule === ruleSO.id) + ) + ) { + return []; + } + // Now that we have an array of building blocks for the events in the sequence, // we can build the signal that links the building blocks together // and also insert the group id (which is also the "shell" signal _id) in each building block diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 4eda9150e52f10..003626e3190075 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -58,7 +58,7 @@ import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects import { getNotificationResultsLink } from '../notifications/utils'; import { TelemetryEventsSender } from '../../telemetry/sender'; import { buildEqlSearchRequest } from '../../../../common/detection_engine/get_query_filter'; -import { bulkInsertSignals } from './single_bulk_create'; +import { bulkInsertSignals, filterDuplicateSignals } from './single_bulk_create'; import { buildSignalFromEvent, buildSignalGroupFromSequence } from './build_bulk_body'; import { createThreatSignals } from './threat_mapping/create_threat_signals'; import { getIndexVersion } from '../routes/index/get_index_version'; @@ -495,16 +495,17 @@ export const signalRulesAlertType = ({ [] ); } else if (response.hits.events !== undefined) { - newSignals = response.hits.events.map((event) => - wrapSignal(buildSignalFromEvent(event, savedObject, true), outputIndex) + newSignals = filterDuplicateSignals( + savedObject.id, + response.hits.events.map((event) => + wrapSignal(buildSignalFromEvent(event, savedObject, true), outputIndex) + ) ); } else { throw new Error( 'eql query response should have either `sequences` or `events` but had neither' ); } - // TODO: replace with code that filters out recursive rule signals while allowing sequences and their building blocks - // const filteredSignals = filterDuplicateSignals(alertId, newSignals); if (newSignals.length > 0) { const insertResult = await bulkInsertSignals(newSignals, logger, services, refresh); result.bulkCreateTimes.push(insertResult.bulkCreateDuration); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index d8889dcfcf4714..8c1d4210a7b364 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -7,7 +7,7 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../alerts/server'; -import { SignalSearchResponse, BulkResponse, SignalHit, BaseSignalHit } from './types'; +import { SignalSearchResponse, BulkResponse, BaseSignalHit } from './types'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams, RefreshTypes } from '../types'; import { generateId, makeFloatString, errorAggregator } from './utils'; @@ -68,9 +68,9 @@ export const filterDuplicateRules = ( * @param ruleId The rule id * @param signals The candidate new signals */ -export const filterDuplicateSignals = (ruleId: string, signals: SignalHit[]) => { +export const filterDuplicateSignals = (ruleId: string, signals: BaseSignalHit[]) => { return signals.filter( - (doc) => !doc.signal.ancestors.some((ancestor) => ancestor.rule === ruleId) + (doc) => !doc._source.signal?.ancestors.some((ancestor) => ancestor.rule === ruleId) ); }; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 8a33b1df4caa8a..d963b3b093d818 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -347,6 +347,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S this.endpointAppContextService.start({ agentService: plugins.fleet?.agentService, packageService: plugins.fleet?.packageService, + packagePolicyService: plugins.fleet?.packagePolicyService, + agentPolicyService: plugins.fleet?.agentPolicyService, appClientFactory: this.appClientFactory, security: this.setupPlugins!.security!, alerts: plugins.alerts, 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 c965623ebfc17e..8936cdafa38279 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -1778,30 +1778,6 @@ } } }, - "infraops": { - "properties": { - "last_24_hours": { - "properties": { - "hits": { - "properties": { - "infraops_hosts": { - "type": "long" - }, - "infraops_docker": { - "type": "long" - }, - "infraops_kubernetes": { - "type": "long" - }, - "logs": { - "type": "long" - } - } - } - } - } - } - }, "ingest_manager": { "properties": { "fleet_enabled": { @@ -1841,6 +1817,30 @@ } } }, + "infraops": { + "properties": { + "last_24_hours": { + "properties": { + "hits": { + "properties": { + "infraops_hosts": { + "type": "long" + }, + "infraops_docker": { + "type": "long" + }, + "infraops_kubernetes": { + "type": "long" + }, + "logs": { + "type": "long" + } + } + } + } + } + } + }, "lens": { "properties": { "events_30_days": { @@ -3136,6 +3136,50 @@ } } }, + "saved_objects_tagging": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + }, + "types": { + "properties": { + "dashboard": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } + }, + "visualization": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } + } + } + } + } + }, "security_solution": { "properties": { "detections": { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index beb6325e4fecda..04b0ef045fffe9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -240,7 +240,6 @@ "apmOss.tutorial.startServer.title": "APM Server の起動", "apmOss.tutorial.windowsServerInstructions.textPost": "注:システムでスクリプトの実行が無効な場合、スクリプトを実行するために現在のセッションの実行ポリシーの設定が必要となります。例: {command}。", "apmOss.tutorial.windowsServerInstructions.textPre": "1.[ダウンロードページ]({downloadPageLink}) から APM Server Windows zip ファイルをダウンロードします。\n2.zip ファイルの内容を {zipFileExtractFolder} に抽出します。\n3.「{apmServerDirectory} ディレクトリの名前を「APM-Server」に変更します。\n4.管理者として PowerShell プロンプトを開きます (PowerShell アイコンを右クリックして「管理者として実行」を選択します)。Windows XP をご使用の場合、PowerShell のダウンロードとインストールが必要な場合があります。\n5.PowerShellプロンプトで次のコマンドを実行し、APM ServerをWindowsサービスとしてインストールします。", - "charts.advancedSettings.visualization.colorMappingText": "ビジュアライゼーション内の特定の色のマップ値です", "charts.advancedSettings.visualization.colorMappingTitle": "カラーマッピング", "charts.colormaps.bluesText": "青", "charts.colormaps.greensText": "緑", @@ -4961,7 +4960,6 @@ "xpack.apm.metadataTable.section.urlLabel": "URL", "xpack.apm.metadataTable.section.userAgentLabel": "ユーザーエージェント", "xpack.apm.metadataTable.section.userLabel": "ユーザー", - "xpack.apm.metrics.plot.noDataLabel": "この時間範囲のデータがありません。", "xpack.apm.metrics.transactionChart.machineLearningLabel": "機械学習:", "xpack.apm.metrics.transactionChart.machineLearningTooltip": "平均期間の周りのストリームには予測バウンドが表示されます。異常スコアが>= 75の場合、注釈が表示されます。", "xpack.apm.metrics.transactionChart.machineLearningTooltip.withKuery": "フィルタリングで検索バーを使用しているときには、機械学習結果が表示されません", @@ -5079,7 +5077,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "1 分あたりのトランザクション", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.servicesTable.UpgradeAssistantLink": "Kibana アップグレードアシスタントで詳細をご覧ください", - "xpack.apm.serviceVersion": "サービスバージョン", "xpack.apm.settings.agentConfig": "エージェントの編集", "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "以前の統合のレガシー機械学習ジョブが見つかりました。これは、APMアプリでは使用されていません。", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "ジョブの確認", @@ -5185,9 +5182,7 @@ "xpack.apm.transactionActionMenu.actionsButtonLabel": "アクション", "xpack.apm.transactionActionMenu.container.subtitle": "このコンテナーのログとインデックスを表示し、さらに詳細を確認できます。", "xpack.apm.transactionActionMenu.container.title": "コンテナーの詳細", - "xpack.apm.transactionActionMenu.customLink.popover.title": "カスタムリンク", "xpack.apm.transactionActionMenu.customLink.section": "カスタムリンク", - "xpack.apm.transactionActionMenu.customLink.seeMore": "詳細を表示", "xpack.apm.transactionActionMenu.customLink.subtitle": "リンクは新しいウィンドウで開きます。", "xpack.apm.transactionActionMenu.host.subtitle": "ホストログとメトリックを表示し、さらに詳細を確認できます。", "xpack.apm.transactionActionMenu.host.title": "ホストの詳細", @@ -5287,7 +5282,6 @@ "xpack.apm.ux.percentile.label": "パーセンタイル", "xpack.apm.ux.title": "ユーザーエクスペリエンス", "xpack.apm.ux.visitorBreakdown.noData": "データがありません。", - "xpack.apm.version": "バージョン", "xpack.apm.waterfall.exceedsMax": "このトレースの項目数は表示されている範囲を超えています", "xpack.beatsManagement.beat.actionSectionTypeLabel": "タイプ: {beatType}。", "xpack.beatsManagement.beat.actionSectionVersionLabel": "バージョン: {beatVersion}", @@ -7176,17 +7170,13 @@ "xpack.fleet.agentDetails.agentVersionLabel": "エージェントバージョン", "xpack.fleet.agentDetails.hostIdLabel": "エージェントID", "xpack.fleet.agentDetails.hostNameLabel": "ホスト名", - "xpack.fleet.agentDetails.localMetadataSectionSubtitle": "メタデータを読み込み中", - "xpack.fleet.agentDetails.metadataSectionTitle": "メタデータ", "xpack.fleet.agentDetails.platformLabel": "プラットフォーム", "xpack.fleet.agentDetails.policyLabel": "ポリシー", "xpack.fleet.agentDetails.releaseLabel": "エージェントリリース", "xpack.fleet.agentDetails.statusLabel": "ステータス", - "xpack.fleet.agentDetails.subTabs.activityLogTab": "アクティビティログ", "xpack.fleet.agentDetails.subTabs.detailsTab": "エージェントの詳細", "xpack.fleet.agentDetails.unexceptedErrorTitle": "エージェントの読み込み中にエラーが発生しました", "xpack.fleet.agentDetails.upgradeAvailableTooltip": "アップグレードが利用可能です", - "xpack.fleet.agentDetails.userProvidedMetadataSectionSubtitle": "ユーザー提供メタデータ", "xpack.fleet.agentDetails.versionLabel": "エージェントバージョン", "xpack.fleet.agentDetails.viewAgentListTitle": "すべてのエージェントを表示", "xpack.fleet.agentEnrollment.agentDescription": "Elasticエージェントをホストに追加し、データを収集して、Elastic Stackに送信します。", @@ -7214,32 +7204,6 @@ "xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle": "Elasticエージェントを登録して実行", "xpack.fleet.agentEnrollment.stepRunAgentDescription": "エージェントのディレクトリから、このコマンドを実行し、Elasticエージェントを、インストール、登録、起動します。このコマンドを再利用すると、複数のホストでエージェントを設定できます。管理者権限が必要です。", "xpack.fleet.agentEnrollment.stepRunAgentTitle": "エージェントの起動", - "xpack.fleet.agentEventsList.collapseDetailsAriaLabel": "詳細を非表示", - "xpack.fleet.agentEventsList.expandDetailsAriaLabel": "詳細を表示", - "xpack.fleet.agentEventsList.messageColumnTitle": "メッセージ", - "xpack.fleet.agentEventsList.messageDetailsTitle": "メッセージ", - "xpack.fleet.agentEventsList.payloadDetailsTitle": "ペイロード", - "xpack.fleet.agentEventsList.refreshButton": "更新", - "xpack.fleet.agentEventsList.searchPlaceholderText": "アクティビティログを検索", - "xpack.fleet.agentEventsList.subtypeColumnTitle": "サブタイプ", - "xpack.fleet.agentEventsList.timestampColumnTitle": "タイムスタンプ", - "xpack.fleet.agentEventsList.typeColumnTitle": "タイプ", - "xpack.fleet.agentEventSubtype.acknowledgedLabel": "認識", - "xpack.fleet.agentEventSubtype.dataDumpLabel": "データダンプ", - "xpack.fleet.agentEventSubtype.degradedLabel": "劣化", - "xpack.fleet.agentEventSubtype.failedLabel": "失敗", - "xpack.fleet.agentEventSubtype.inProgressLabel": "進行中", - "xpack.fleet.agentEventSubtype.policyLabel": "ポリシー", - "xpack.fleet.agentEventSubtype.runningLabel": "実行中", - "xpack.fleet.agentEventSubtype.startingLabel": "開始中", - "xpack.fleet.agentEventSubtype.stoppedLabel": "停止", - "xpack.fleet.agentEventSubtype.stoppingLabel": "停止中", - "xpack.fleet.agentEventSubtype.unknownLabel": "不明", - "xpack.fleet.agentEventSubtype.updatingLabel": "更新中", - "xpack.fleet.agentEventType.actionLabel": "アクション", - "xpack.fleet.agentEventType.actionResultLabel": "アクション結果", - "xpack.fleet.agentEventType.errorLabel": "エラー", - "xpack.fleet.agentEventType.stateLabel": "ステータス", "xpack.fleet.agentHealth.checkInTooltipText": "前回のチェックイン {lastCheckIn}", "xpack.fleet.agentHealth.degradedStatusText": "劣化", "xpack.fleet.agentHealth.enrollingStatusText": "登録中", @@ -7585,10 +7549,6 @@ "xpack.fleet.invalidLicenseTitle": "ライセンスの期限切れ", "xpack.fleet.listTabs.agentTitle": "エージェント", "xpack.fleet.listTabs.enrollmentTokensTitle": "登録トークン", - "xpack.fleet.metadataForm.addButton": "+ メタデータを追加", - "xpack.fleet.metadataForm.keyLabel": "キー", - "xpack.fleet.metadataForm.submitButtonText": "追加", - "xpack.fleet.metadataForm.valueLabel": "値", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "名前空間に無効な文字が含まれています", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "名前空間は小文字で指定する必要があります", "xpack.fleet.namespaceValidation.requiredErrorMessage": "名前空間は必須です", @@ -19494,307 +19454,85 @@ "xpack.stackAlerts.indexThreshold.actionVariableContextThresholdLabel": "しきい値として使用する値の配列。「between」と「notBetween」には2つの値が必要です。その他は1つの値が必要です。", "xpack.stackAlerts.indexThreshold.actionVariableContextTitleLabel": "アラートの事前構成タイトル。", "xpack.stackAlerts.indexThreshold.actionVariableContextValueLabel": "しきい値を超えた値。", - "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggType]が「{aggType}」のときには[aggField]に値が必要です", "xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription": "アラート{name}グループ{group}値{value}が{date}に{window}にわたってしきい値{function}を超えました", "xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "アラート{name}グループ{group}がしきい値を超えました", "xpack.stackAlerts.indexThreshold.alertTypeTitle": "インデックスしきい値", + "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効なthresholdComparatorが指定されました: {comparator}", + "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]: 「{thresholdComparator}」比較子の場合には2つの要素が必要です", + "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "境界名を選択", + "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)", + "xpack.stackAlerts.geoThreshold.delayOffset": "遅延評価オフセット", + "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "遅延サイクルでアラートを評価し、データレイテンシに合わせて調整します", + "xpack.stackAlerts.geoThreshold.entityByLabel": "グループ基準", + "xpack.stackAlerts.geoThreshold.entityIndexLabel": "インデックス", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "境界インデックスパターンタイトルは必須です。", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "境界タイプは必須です。", + "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "日付フィールドが必要です。", + "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "エンティティは必須です。", + "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "地理フィールドは必須です。", + "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "インデックスパターンが必要です。", + "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "追跡イベントは必須です。", + "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", + "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空間フィールド", + "xpack.stackAlerts.geoThreshold.indexLabel": "インデックス", + "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "インデックスパターン", + "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "インデックスパターンを選択", + "xpack.stackAlerts.geoThreshold.name.trackingThreshold": "追跡しきい値", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "インデックスパターンを作成します", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "次のことが必要です ", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " 地理空間フィールドを含む", + "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。", + "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "地理空間データセットがありませんか? ", + "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "地理空間フィールドを含むインデックスパターンが見つかりませんでした", + "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "境界を選択:", + "xpack.stackAlerts.geoThreshold.selectEntity": "エンティティを選択", + "xpack.stackAlerts.geoThreshold.selectGeoLabel": "ジオフィールドを選択", + "xpack.stackAlerts.geoThreshold.selectIndex": "条件を定義してください", + "xpack.stackAlerts.geoThreshold.selectLabel": "ジオフィールドを選択", + "xpack.stackAlerts.geoThreshold.selectOffset": "オフセットを選択(任意)", + "xpack.stackAlerts.geoThreshold.selectTimeLabel": "時刻フィールドを選択", + "xpack.stackAlerts.geoThreshold.timeFieldLabel": "時間フィールド", + "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", + "xpack.stackAlerts.geoThreshold.whenEntityLabel": "エンティティ", + "xpack.stackAlerts.threshold.ui.validation.error.greaterThenThreshold0Text": "しきい値 1 はしきい値 0 よりも大きい値にしてください。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredAggFieldText": "集約フィールドが必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredIndexText": "インデックスが必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTermSizedText": "用語サイズが必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold0Text": "しきい値 0 が必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold1Text": "しきい値 1 が必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeFieldText": "時間フィールドが必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeWindowSizeText": "時間ウィンドウサイズが必要です。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTermFieldText": "用語フィールドが必要です。", + "xpack.stackAlerts.threshold.ui.conditionPrompt": "条件を定義してください", + "xpack.stackAlerts.threshold.ui.visualization.errorLoadingAlertVisualizationTitle": "アラートビジュアライゼーションを読み込めません", + "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "閉じる", + "xpack.stackAlerts.threshold.ui.visualization.loadingAlertVisualizationDescription": "アラートビジュアライゼーションを読み込み中...", + "xpack.stackAlerts.threshold.ui.previewAlertVisualizationDescription": "プレビューを生成するための式を完成します。", + "xpack.stackAlerts.threshold.ui.selectIndex": "インデックスを選択してください", + "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "閉じる", + "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", + "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "* で検索クエリの範囲を広げます。", + "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "インデックス", + "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "インデックス", + "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "クエリを実行するインデックス", + "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "時間フィールド", + "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.dataDoesNotExistTextMessage": "時間範囲とフィルターが正しいことを確認してください。", + "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.noDataTitle": "このクエリに一致するデータはありません", + "xpack.stackAlerts.threshold.ui.visualization.unableToLoadVisualizationMessage": "ビジュアライゼーションを読み込めません", + "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggType]が「{aggType}」のときには[aggField]に値が必要です", "xpack.triggersActionsUI.data.coreQueryParams.dateStartGTdateEndErrorMessage": "[dateStart]が[dateEnd]よりも大です", "xpack.triggersActionsUI.data.coreQueryParams.formattedFieldErrorMessage": "{fieldName}の無効な{formatName}形式:「{fieldValue}」", "xpack.triggersActionsUI.data.coreQueryParams.intervalRequiredErrorMessage": "[interval]: [dateStart]が[dateEnd]と等しくない場合に指定する必要があります", "xpack.triggersActionsUI.data.coreQueryParams.invalidAggTypeErrorMessage": "無効な aggType:「{aggType}」", - "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効なthresholdComparatorが指定されました: {comparator}", "xpack.triggersActionsUI.data.coreQueryParams.invalidDateErrorMessage": "無効な日付{date}", "xpack.triggersActionsUI.data.coreQueryParams.invalidDurationErrorMessage": "無効な期間:「{duration}」", "xpack.triggersActionsUI.data.coreQueryParams.invalidGroupByErrorMessage": "無効なgroupBy:「{groupBy}」", "xpack.triggersActionsUI.data.coreQueryParams.invalidTermSizeMaximumErrorMessage": "[termSize]: {maxGroups}以下でなければなりません。", - "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]: 「{thresholdComparator}」比較子の場合には2つの要素が必要です", "xpack.triggersActionsUI.data.coreQueryParams.invalidTimeWindowUnitsErrorMessage": "無効なtimeWindowUnit:「{timeWindowUnit}」", "xpack.triggersActionsUI.data.coreQueryParams.maxIntervalsErrorMessage": "間隔{intervals}の計算値が{maxIntervals}よりも大です", "xpack.triggersActionsUI.data.coreQueryParams.termFieldRequiredErrorMessage": "[termField]: [groupBy]がトップのときにはtermFieldが必要です", "xpack.triggersActionsUI.data.coreQueryParams.termSizeRequiredErrorMessage": "[termSize]: [groupBy]がトップのときにはtermSizeが必要です", - "xpack.transform.actionDeleteTransform.bulkDeleteDestinationIndexTitle": "ディスティネーションインデックスの削除", - "xpack.transform.actionDeleteTransform.bulkDeleteDestIndexPatternTitle": "ディスティネーションインデックスパターンの削除", - "xpack.transform.actionDeleteTransform.deleteDestinationIndexTitle": "ディスティネーションインデックス{destinationIndex}の削除", - "xpack.transform.actionDeleteTransform.deleteDestIndexPatternTitle": "インデックスパターン{destinationIndex}の削除", - "xpack.transform.agg.popoverForm.aggLabel": "集約", - "xpack.transform.agg.popoverForm.aggNameAlreadyUsedError": "別の集約で既に同じ名前が使用されています。", - "xpack.transform.agg.popoverForm.aggNameInvalidCharError": "無効な名前です。「[」、「]」「>」は使用できず、名前の始めと終わりにはスペースを使用できません。", - "xpack.transform.agg.popoverForm.fieldLabel": "フィールド", - "xpack.transform.agg.popoverForm.filerAgg.range.greaterThanLabel": "より大きい", - "xpack.transform.agg.popoverForm.filerAgg.range.lessThanLabel": "より小さい", - "xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions": "候補を取得できません", - "xpack.transform.agg.popoverForm.filerAgg.term.valueLabel": "値", - "xpack.transform.agg.popoverForm.filerAggLabel": "フィルタークエリ", - "xpack.transform.agg.popoverForm.nameLabel": "集約名", - "xpack.transform.agg.popoverForm.percentsLabel": "パーセント", - "xpack.transform.agg.popoverForm.submitButtonLabel": "適用", - "xpack.transform.agg.popoverForm.unsupportedAggregationHelpText": "このフォームでは集約名のみを編集できます。詳細エディターを使用して、集約の他の部分を編集してください。", - "xpack.transform.aggLabelForm.deleteItemAriaLabel": "アイテムを削除", - "xpack.transform.aggLabelForm.editAggAriaLabel": "集約を編集", - "xpack.transform.app.checkingPrivilegesDescription": "権限を確認中…", - "xpack.transform.app.checkingPrivilegesErrorMessage": "サーバーからユーザー特権を取得中にエラーが発生。", - "xpack.transform.app.deniedPrivilegeDescription": "Transforms のこのセクションを使用するには、{privilegesCount, plural, one {このクラスター特権} other {これらのクラスター特権}}が必要です: {missingPrivileges}。", - "xpack.transform.app.deniedPrivilegeTitle": "クラスター特権が足りません", - "xpack.transform.appName": "データフレームジョブ", - "xpack.transform.appTitle": "変換", - "xpack.transform.capability.noPermission.createTransformTooltip": "データフレーム変換を作成するパーミッションがありません。", - "xpack.transform.capability.noPermission.deleteTransformTooltip": "データフレーム変換を削除するパーミッションがありません。", - "xpack.transform.capability.noPermission.startOrStopTransformTooltip": "データフレーム変換を開始・停止するパーミッションがありません。", - "xpack.transform.capability.pleaseContactAdministratorTooltip": "{message} 管理者にお問い合わせください。", - "xpack.transform.clone.errorPromptText": "ソースインデックスパターンが存在するかどうかを確認するときにエラーが発生しました", - "xpack.transform.clone.errorPromptTitle": "変換構成の取得中にエラーが発生しました。", - "xpack.transform.clone.fetchErrorPromptText": "KibanaインデックスパターンIDを取得できませんでした。", - "xpack.transform.clone.noIndexPatternErrorPromptText": "変換を複製できません{indexPattern}のインデックスパターンが存在しません。", - "xpack.transform.cloneTransform.breadcrumbTitle": "クローン変換", - "xpack.transform.createTransform.breadcrumbTitle": "変換の作成", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "ディスティネーションインデックス{destinationIndex}の削除中にエラーが発生しました", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage": "インデックスパターン{destinationIndex}の削除中にエラーが発生しました", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage": "インデックスパターン{destinationIndex}を削除する要求が確認されました。", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage": "ディスティネーションインデックス{destinationIndex}を削除する要求が確認されました。", - "xpack.transform.deleteTransform.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage": "インデックスパターン{indexPattern}が存在するかどうかを確認するときにエラーが発生しました。{error}", - "xpack.transform.description": "説明", - "xpack.transform.groupby.popoverForm.aggLabel": "集約", - "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "別のグループ分けの構成が既にこの名前を使用しています。", - "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "無効な名前です。「[」、「]」「>」は使用できず、名前の始めと終わりにはスペースを使用できません。", - "xpack.transform.groupBy.popoverForm.fieldLabel": "フィールド", - "xpack.transform.groupBy.popoverForm.intervalError": "無効な間隔。", - "xpack.transform.groupBy.popoverForm.intervalLabel": "間隔", - "xpack.transform.groupBy.popoverForm.intervalPercents": "パーセンタイルをコンマで区切って列記します。", - "xpack.transform.groupBy.popoverForm.nameLabel": "グループ分け名", - "xpack.transform.groupBy.popoverForm.submitButtonLabel": "適用", - "xpack.transform.groupBy.popoverForm.unsupportedGroupByHelpText": "このフォームでは group_by 名のみを編集できます。詳細エディターを使用して、group_by 構成の他の部分を編集してください。", - "xpack.transform.groupByLabelForm.deleteItemAriaLabel": "アイテムを削除", - "xpack.transform.groupByLabelForm.editIntervalAriaLabel": "間隔を編集", - "xpack.transform.home.breadcrumbTitle": "データフレームジョブ", - "xpack.transform.indexPreview.copyClipboardTooltip": "インデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。", - "xpack.transform.licenseCheckErrorMessage": "ライセンス確認失敗", - "xpack.transform.list.emptyPromptButtonText": "初めての変換を作成してみましょう。", - "xpack.transform.list.emptyPromptTitle": "変換が見つかりません", - "xpack.transform.list.errorPromptTitle": "変換リストの取得中にエラーが発生しました。", - "xpack.transform.mode": "モード", - "xpack.transform.modeFilter": "モード", - "xpack.transform.models.transformService.allOtherRequestsCancelledDescription": "他のすべてのリクエストはキャンセルされました。", - "xpack.transform.models.transformService.requestToActionTimedOutErrorMessage": "「{id}」を{action}するリクエストがタイムアウトしました。{extra}", - "xpack.transform.multiTransformActionsMenu.managementActionsAriaLabel": "管理アクション", - "xpack.transform.multiTransformActionsMenu.transformsCount": "{count} 件の{count, plural, one {変換} other {変換}}を選択済み", - "xpack.transform.newTransform.chooseSourceTitle": "ソースの選択", - "xpack.transform.newTransform.newTransformTitle": "新規変換", - "xpack.transform.newTransform.searchSelection.notFoundLabel": "一致するインデックスまたは保存検索が見つかりませんでした。", - "xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "インデックスパターン", - "xpack.transform.newTransform.searchSelection.savedObjectType.search": "保存検索", - "xpack.transform.pivotPreview.copyClipboardTooltip": "ピボットプレビューの開発コンソールステートメントをクリップボードにコピーします。", - "xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "group-by フィールドと集約を 1 つ以上選んでください。", - "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "プレビューリクエストはデータを返しませんでした。オプションのクエリがデータを返し、グループ分け基準により使用されるフィールドと集約フィールドに値が存在することを確認してください。", - "xpack.transform.pivotPreview.PivotPreviewTitle": "ピボットプレビューを変換", - "xpack.transform.progress": "進捗", - "xpack.transform.statsBar.batchTransformsLabel": "一斉", - "xpack.transform.statsBar.continuousTransformsLabel": "連続", - "xpack.transform.statsBar.failedTransformsLabel": "失敗", - "xpack.transform.statsBar.startedTransformsLabel": "開始済み", - "xpack.transform.statsBar.totalTransformsLabel": "変換合計", - "xpack.transform.status": "ステータス", - "xpack.transform.statusFilter": "ステータス", - "xpack.transform.stepCreateForm.continuousModeLabel": "連続モード", - "xpack.transform.stepCreateForm.copyTransformConfigToClipboardButton": "クリップボードにコピー", - "xpack.transform.stepCreateForm.copyTransformConfigToClipboardDescription": "ジョブを作成する Kibana 開発コンソールのコマンドをクリップボードにコピーします。", - "xpack.transform.stepCreateForm.createAndStartTransformButton": "作成して開始", - "xpack.transform.stepCreateForm.createAndStartTransformDescription": "変換を作成して開始します。変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。変換の開始後、変換の閲覧を続けるオプションが提供されます。", - "xpack.transform.stepCreateForm.createIndexPatternErrorMessage": "Kibana インデックスパターン {indexPatternName} の作成中にエラーが発生しました:", - "xpack.transform.stepCreateForm.createIndexPatternLabel": "インデックスパターンを作成", - "xpack.transform.stepCreateForm.createIndexPatternSuccessMessage": "Kibana インデックスパターン {indexPatternName} が作成されました", - "xpack.transform.stepCreateForm.createTransformButton": "作成", - "xpack.transform.stepCreateForm.createTransformDescription": "変換を開始せずに作成します。変換は後程変換リストに戻って開始できます。", - "xpack.transform.stepCreateForm.createTransformErrorMessage": "変換 {transformId} の取得中にエラーが発生しました。", - "xpack.transform.stepCreateForm.createTransformSuccessMessage": "変換 {transformId} の作成リクエストが受け付けられました。", - "xpack.transform.stepCreateForm.creatingIndexPatternMessage": "Kibana インデックスパターンを作成中…", - "xpack.transform.stepCreateForm.discoverCardDescription": "ディスカバリでデータフレームピボットを閲覧します。", - "xpack.transform.stepCreateForm.discoverCardTitle": "ディスカバー", - "xpack.transform.stepCreateForm.duplicateIndexPatternErrorMessage": "Kibana インデックスパターン {indexPatternName} の作成中にエラーが発生しました:インデックスパターンが既に存在します。", - "xpack.transform.stepCreateForm.progressErrorMessage": "進捗パーセンテージの取得中にエラーが発生しました:", - "xpack.transform.stepCreateForm.progressTitle": "進捗", - "xpack.transform.stepCreateForm.startTransformButton": "開始", - "xpack.transform.stepCreateForm.startTransformDescription": "変換を開始します。変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。変換の開始後、変換の閲覧を続けるオプションが提供されます。", - "xpack.transform.stepCreateForm.startTransformErrorMessage": "変換 {transformId} の開始中にエラーが発生しました。", - "xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage": "変換開始要求の呼び出し中にエラーが発生しました。", - "xpack.transform.stepCreateForm.startTransformSuccessMessage": "変換 {transformId} の開始リクエストが受け付けられました。", - "xpack.transform.stepCreateForm.transformListCardDescription": "データフレームジョブの管理ページに戻ります。", - "xpack.transform.stepCreateForm.transformListCardTitle": "データフレームジョブ", - "xpack.transform.stepDefineForm.addSubAggregationPlaceholder": "下位集約を追加...", - "xpack.transform.stepDefineForm.advancedEditorApplyButtonText": "変更を適用", - "xpack.transform.stepDefineForm.advancedEditorAriaLabel": "高度なピボットエディター", - "xpack.transform.stepDefineForm.advancedEditorHelpText": "詳細エディターでは、変換のピボット構成を編集できます。", - "xpack.transform.stepDefineForm.advancedEditorHelpTextLink": "使用可能なオプションの詳細を確認してください。", - "xpack.transform.stepDefineForm.advancedEditorLabel": "ピボット構成オブジェクト", - "xpack.transform.stepDefineForm.advancedEditorSourceConfigSwitchLabel": "JSONクエリを編集", - "xpack.transform.stepDefineForm.advancedEditorSwitchLabel": "JSON構成を編集", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalBodyText": "詳細エディターの変更は適用されませんでした。詳細エディターを無効にすると、編集内容が失われます。", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalCancelButtonText": "キャンセル", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalConfirmButtonText": "詳細エディターを無効にする", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalTitle": "適用されていない変更", - "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "変更を適用", - "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "クエリの詳細エディター", - "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高度なエディターでは、変換構成のソースクエリ句を編集できます。", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "クエリバーに戻すと、編集内容が失われます。", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "クエリバーに切り替え", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "編集内容は失われます", - "xpack.transform.stepDefineForm.aggExistsErrorMessage": "「{aggName}」という名前の集約構成は既に存在します。", - "xpack.transform.stepDefineForm.aggregationsLabel": "アグリゲーション(集計)", - "xpack.transform.stepDefineForm.aggregationsPlaceholder": "集約を追加…", - "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "「{aggName}」という名前のグループ分け構成は既に存在します。", - "xpack.transform.stepDefineForm.groupByLabel": "グループ分けの条件", - "xpack.transform.stepDefineForm.groupByPlaceholder": "グループ分けの条件フィールドを追加…", - "xpack.transform.stepDefineForm.indexPatternLabel": "インデックスパターン", - "xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar": "無効なクエリ:{errorMessage}", - "xpack.transform.stepDefineForm.maxSubAggsLevelsLimitMessage": "フォームで追加できる下位集約の最大レベル数に達しました。別のレベルを追加する場合は、JSON構成を編集してください。", - "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "「{aggListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", - "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "「{aggNameCheck}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", - "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "「{groupByListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", - "xpack.transform.stepDefineForm.queryPlaceholderKql": "例: {example}", - "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例: {example}", - "xpack.transform.stepDefineForm.savedSearchLabel": "保存検索", - "xpack.transform.stepDefineSummary.aggregationsLabel": "アグリゲーション(集計)", - "xpack.transform.stepDefineSummary.groupByLabel": "グループ分けの条件", - "xpack.transform.stepDefineSummary.indexPatternLabel": "インデックスパターン", - "xpack.transform.stepDefineSummary.queryCodeBlockLabel": "クエリ", - "xpack.transform.stepDefineSummary.queryLabel": "クエリ", - "xpack.transform.stepDefineSummary.savedSearchLabel": "保存検索", - "xpack.transform.stepDetailsForm.advancedSettingsAccordionButtonContent": "高度な設定", - "xpack.transform.stepDetailsForm.continuousModeAriaLabel": "遅延を選択してください。", - "xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText": "新しいドキュメントを特定するために使用できる日付フィールドを選択してください。", - "xpack.transform.stepDetailsForm.continuousModeDateFieldLabel": "日付フィールド", - "xpack.transform.stepDetailsForm.continuousModeDelayError": "無効な遅延フォーマット", - "xpack.transform.stepDetailsForm.continuousModeDelayHelpText": "現在の時刻と最新のインプットデータ時刻の間の遅延です。", - "xpack.transform.stepDetailsForm.continuousModeDelayLabel": "遅延", - "xpack.transform.stepDetailsForm.continuousModeError": "日付フィールドがないインデックスでは、連続モードを使用できません。", - "xpack.transform.stepDetailsForm.destinationIndexHelpText": "この名前のインデックスが既に存在します。この変換を実行すると、デスティネーションインデックスが変更されます。", - "xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel": "固有の宛先インデックス名を選択してください。", - "xpack.transform.stepDetailsForm.destinationIndexInvalidError": "無効なデスティネーションインデックス名。", - "xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink": "インデックス名の制限に関する詳細。", - "xpack.transform.stepDetailsForm.destinationIndexLabel": "デスティネーションインデックス", - "xpack.transform.stepDetailsForm.editFlyoutFormFrequencyPlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.stepDetailsForm.editFlyoutFormMaxPageSearchSizePlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.stepDetailsForm.errorGettingIndexNames": "既存のインデックス名の取得中にエラーが発生しました:", - "xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました:", - "xpack.transform.stepDetailsForm.errorGettingTransformList": "既存の変換 ID の取得中にエラーが発生しました:", - "xpack.transform.stepDetailsForm.errorGettingTransformPreview": "変換プレビューの取得中にエラーが発生しました。", - "xpack.transform.stepDetailsForm.frequencyAriaLabel": "頻度を選択してください。", - "xpack.transform.stepDetailsForm.frequencyError": "無効な頻度形式", - "xpack.transform.stepDetailsForm.frequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。", - "xpack.transform.stepDetailsForm.frequencyLabel": "頻度", - "xpack.transform.stepDetailsForm.indexPatternTimeFieldHelpText": "グローバル時間フィルターで使用するためのプライマリ時間フィールドを選択してください。", - "xpack.transform.stepDetailsForm.indexPatternTimeFieldLabel": "時間フィールド", - "xpack.transform.stepDetailsForm.indexPatternTitleError": "このタイトルのインデックスパターンが既に存在します。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeAriaLabel": "最大ページ検索サイズを選択してください。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeError": "max_page_search_sizeは10~10000の範囲の数値でなければなりません。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeLabel": "最大ページ検索サイズ", - "xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "時間フィルターを使用しない", - "xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "オプションの変換の説明を選択してください。", - "xpack.transform.stepDetailsForm.transformDescriptionLabel": "変換の説明", - "xpack.transform.stepDetailsForm.transformDescriptionPlaceholderText": "説明(オプション)", - "xpack.transform.stepDetailsForm.transformIdExistsError": "この ID の変換が既に存在します。", - "xpack.transform.stepDetailsForm.transformIdInputAriaLabel": "固有のジョブ ID を選択してください。", - "xpack.transform.stepDetailsForm.transformIdInvalidError": "小文字のアルファベットと数字 (a-z と 0-9)、ハイフンまたはアンダーラインのみ使用でき、最初と最後を英数字にする必要があります。", - "xpack.transform.stepDetailsForm.transformIdLabel": "ジョブ ID", - "xpack.transform.stepDetailsSummary.advancedSettingsAccordionButtonContent": "高度な設定", - "xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "連続モード日付フィールド", - "xpack.transform.stepDetailsSummary.createIndexPatternMessage": "このジョブの Kibana インデックスパターンが作成されます。", - "xpack.transform.stepDetailsSummary.destinationIndexLabel": "デスティネーションインデックス", - "xpack.transform.stepDetailsSummary.frequencyLabel": "頻度", - "xpack.transform.stepDetailsSummary.indexPatternTimeFieldLabel": "Kibanaインデックスパターン時間フィールド", - "xpack.transform.stepDetailsSummary.maxPageSearchSizeLabel": "最大ページ検索サイズ", - "xpack.transform.stepDetailsSummary.transformDescriptionLabel": "変換の説明", - "xpack.transform.stepDetailsSummary.transformIdLabel": "ジョブ ID", - "xpack.transform.tableActionLabel": "アクション", - "xpack.transform.toastText.closeModalButtonText": "閉じる", - "xpack.transform.toastText.modalTitle": "詳細を入力", - "xpack.transform.toastText.openModalButtonText": "詳細を表示", - "xpack.transform.transformForm.sizeNotationPlaceholder": "例: {example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage": "{count}個のディスティネーションインデックス{count, plural, one {パターン} other {パターン}}を正常に削除しました。", - "xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage": "{count}個のディスティネーション{count, plural, one {インデックス} other {インデックス}}を正常に削除しました。", - "xpack.transform.transformList.bulkDeleteModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を削除", - "xpack.transform.transformList.bulkDeleteTransformSuccessMessage": "{count} {count, plural, one {個の変換} other {個の変換}}を正常に削除しました。", - "xpack.transform.transformList.bulkStartModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を開始", - "xpack.transform.transformList.cloneActionNameText": "クローンを作成", - "xpack.transform.transformList.completeBatchTransformBulkActionToolTip": "1 つまたは複数の変換が完了済みの一斉変換で、再度開始できません。", - "xpack.transform.transformList.completeBatchTransformToolTip": "{transformId} は完了済みの一斉変換で、再度開始できません。", - "xpack.transform.transformList.createTransformButton": "変換の作成", - "xpack.transform.transformList.deleteActionDisabledToolTipContent": "削除するにはデータフレームジョブを停止してください。", - "xpack.transform.transformList.deleteActionNameText": "削除", - "xpack.transform.transformList.deleteBulkActionDisabledToolTipContent": "削除するには、選択された変換のうちの 1 つまたは複数を停止する必要があります。", - "xpack.transform.transformList.deleteModalCancelButton": "キャンセル", - "xpack.transform.transformList.deleteModalDeleteButton": "削除", - "xpack.transform.transformList.deleteModalTitle": "{transformId}を削除しますか?", - "xpack.transform.transformList.deleteTransformErrorMessage": "変換 {transformId} の削除中にエラーが発生しました", - "xpack.transform.transformList.deleteTransformGenericErrorMessage": "変換を削除するための API エンドポイントの呼び出し中にエラーが発生しました。", - "xpack.transform.transformList.deleteTransformSuccessMessage": "変換 {transformId} の削除リクエストが受け付けられました。", - "xpack.transform.transformList.editActionNameText": "編集", - "xpack.transform.transformList.editFlyoutCalloutDocs": "ドキュメントを表示", - "xpack.transform.transformList.editFlyoutCalloutText": "このフォームでは、変換を更新できます。更新できるプロパティのリストは、変換を作成するときに定義できるリストのサブセットです。", - "xpack.transform.transformList.editFlyoutCancelButtonText": "キャンセル", - "xpack.transform.transformList.editFlyoutFormAdvancedSettingsButtonContent": "高度な設定", - "xpack.transform.transformList.editFlyoutFormDescriptionLabel": "説明", - "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "ディスティネーション構成", - "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "デスティネーションインデックス", - "xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "パイプライン", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "スロットリングを有効にするには、毎秒入力するドキュメントの上限を設定します。", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "毎秒あたりのドキュメント", - "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。", - "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "頻度", - "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "頻度値が無効です。", - "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大ページ検索サイズ", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "値は1以上の整数でなければなりません。", - "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "値は10~10000の範囲の整数でなければなりません。", - "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必須フィールド。", - "xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage": "値は文字列型でなければなりません。", - "xpack.transform.transformList.editFlyoutTitle": "{transformId}を編集", - "xpack.transform.transformList.editFlyoutUpdateButtonText": "更新", - "xpack.transform.transformList.editTransformGenericErrorMessage": "変換を削除するためのAPIエンドポイントの呼び出し中にエラーが発生しました。", - "xpack.transform.transformList.editTransformSuccessMessage": "変換{transformId}が更新されました。", - "xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage": "ユーザーがディスティネーションインデックスを削除できるかどうかを確認するときにエラーが発生しました。", - "xpack.transform.transformList.refreshButtonLabel": "更新", - "xpack.transform.transformList.rowCollapse": "{transformId} の詳細を非表示", - "xpack.transform.transformList.rowExpand": "{transformId} の詳細を表示", - "xpack.transform.transformList.showDetailsColumn.screenReaderDescription": "このカラムには変換ごとの詳細を示すクリック可能なコントロールが含まれます", - "xpack.transform.transformList.startActionNameText": "開始", - "xpack.transform.transformList.startedTransformBulkToolTip": "1 つまたは複数の変換が既に開始済みです。", - "xpack.transform.transformList.startedTransformToolTip": "{transformId} は既に開始済みです。", - "xpack.transform.transformList.startModalBody": "変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。", - "xpack.transform.transformList.startModalCancelButton": "キャンセル", - "xpack.transform.transformList.startModalStartButton": "開始", - "xpack.transform.transformList.startModalTitle": "{transformId}を開始しますか?", - "xpack.transform.transformList.startTransformErrorMessage": "変換 {transformId} の開始中にエラーが発生しました", - "xpack.transform.transformList.startTransformSuccessMessage": "変換 {transformId} の開始リクエストが受け付けられました。", - "xpack.transform.transformList.stopActionNameText": "終了", - "xpack.transform.transformList.stoppedTransformBulkToolTip": "1 つまたは複数の変換が既に開始済みです。", - "xpack.transform.transformList.stoppedTransformToolTip": "{transformId} は既に停止済みです。", - "xpack.transform.transformList.stopTransformErrorMessage": "データフレーム変換 {transformId} の停止中にエラーが発生しました", - "xpack.transform.transformList.stopTransformResponseSchemaErrorMessage": "変換停止要求の呼び出し中にエラーが発生しました。", - "xpack.transform.transformList.stopTransformSuccessMessage": "データフレーム変換 {transformId} の停止リクエストが受け付けられました。", - "xpack.transform.transformList.transformDescription": "変換を使用して、集約されたインデックスまたはエンティティ中心のインデックスに、既存のElasticsearchインデックスをインデックスします。", - "xpack.transform.transformList.transformDetails.messagesPane.errorMessage": "メッセージを読み込めませんでした", - "xpack.transform.transformList.transformDetails.messagesPane.messageLabel": "メッセージ", - "xpack.transform.transformList.transformDetails.messagesPane.nodeLabel": "ノード", - "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "時間", - "xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel": "詳細", - "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "メッセージ", - "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "プレビュー", - "xpack.transform.transformList.transformDetails.tabs.transformStatsLabel": "統計", - "xpack.transform.transformList.transformDocsLinkText": "変換ドキュメント", - "xpack.transform.transformList.transformTitle": "データフレームジョブ", - "xpack.transform.transformsDescription": "変換を使用して、集約されたインデックスまたはエンティティ中心のインデックスに、既存のElasticsearchインデックスをインデックスします。", - "xpack.transform.transformsTitle": "変換", - "xpack.transform.transformsWizard.cloneTransformTitle": "クローン変換", - "xpack.transform.transformsWizard.createTransformTitle": "変換の作成", - "xpack.transform.transformsWizard.stepConfigurationTitle": "構成", - "xpack.transform.transformsWizard.stepCreateTitle": "作成", - "xpack.transform.transformsWizard.stepDetailsTitle": "ジョブの詳細", - "xpack.transform.transformsWizard.transformDocsLinkText": "変換ドキュメント", - "xpack.transform.wizard.nextStepButton": "次へ", - "xpack.transform.wizard.previousStepButton": "前へ", "xpack.triggersActionsUI.actionVariables.alertIdLabel": "アラートの ID。", "xpack.triggersActionsUI.actionVariables.alertInstanceIdLabel": "アラートのアクションを予定したアラートインスタンス ID。", "xpack.triggersActionsUI.actionVariables.alertNameLabel": "アラートの名前。", @@ -20047,42 +19785,6 @@ "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel": "キャンセル", "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel": "{numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}}を削除 ", "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText": "{numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}を回復できません。", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "境界名を選択", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)", - "xpack.stackAlerts.geoThreshold.delayOffset": "遅延評価オフセット", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "遅延サイクルでアラートを評価し、データレイテンシに合わせて調整します", - "xpack.stackAlerts.geoThreshold.entityByLabel": "グループ基準", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "境界インデックスパターンタイトルは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "境界タイプは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "日付フィールドが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "エンティティは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "インデックスパターンが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "追跡イベントは必須です。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空間フィールド", - "xpack.stackAlerts.geoThreshold.indexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "インデックスパターン", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "インデックスパターンを選択", - "xpack.stackAlerts.geoThreshold.name.trackingThreshold": "追跡しきい値", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "インデックスパターンを作成します", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "次のことが必要です ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " 地理空間フィールドを含む", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "地理空間データセットがありませんか? ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "地理空間フィールドを含むインデックスパターンが見つかりませんでした", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "境界を選択:", - "xpack.stackAlerts.geoThreshold.selectEntity": "エンティティを選択", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectIndex": "条件を定義してください", - "xpack.stackAlerts.geoThreshold.selectLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectOffset": "オフセットを選択(任意)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "時刻フィールドを選択", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "時間フィールド", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "エンティティ", "xpack.triggersActionsUI.home.alertsTabTitle": "アラート", "xpack.triggersActionsUI.home.appTitle": "アラートとアクション", "xpack.triggersActionsUI.home.breadcrumbTitle": "アラートとアクション", @@ -20125,15 +19827,6 @@ "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderValueText": "値が必要です。", "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText": "メソッドが必要です", "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText": "パスワードが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.greaterThenThreshold0Text": "しきい値 1 はしきい値 0 よりも大きい値にしてください。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredAggFieldText": "集約フィールドが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredIndexText": "インデックスが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTermSizedText": "用語サイズが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold0Text": "しきい値 0 が必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold1Text": "しきい値 1 が必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeFieldText": "時間フィールドが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeWindowSizeText": "時間ウィンドウサイズが必要です。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTermFieldText": "用語フィールドが必要です。", "xpack.triggersActionsUI.sections.addConnectorForm.flyoutTitle": "{actionTypeName} コネクタ", "xpack.triggersActionsUI.sections.addConnectorForm.selectConnectorFlyoutTitle": "コネクターを選択", "xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText": "コネクターを作成できません。", @@ -20142,27 +19835,11 @@ "xpack.triggersActionsUI.sections.addModalConnectorForm.flyoutTitle": "{actionTypeName} コネクター", "xpack.triggersActionsUI.sections.addModalConnectorForm.saveButtonLabel": "保存", "xpack.triggersActionsUI.sections.addModalConnectorForm.updateSuccessNotificationText": "「{connectorName}」を作成しました", - "xpack.stackAlerts.threshold.ui.conditionPrompt": "条件を定義してください", - "xpack.stackAlerts.threshold.ui.visualization.errorLoadingAlertVisualizationTitle": "アラートビジュアライゼーションを読み込めません", "xpack.triggersActionsUI.sections.alertAdd.flyoutTitle": "アラートの作成", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.threshold.ui.visualization.loadingAlertVisualizationDescription": "アラートビジュアライゼーションを読み込み中...", "xpack.triggersActionsUI.sections.alertAdd.operationName": "作成", - "xpack.stackAlerts.threshold.ui.previewAlertVisualizationDescription": "プレビューを生成するための式を完成します。", "xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText": "アラートを作成できません。", "xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText": "「{alertName}」 を保存しました", - "xpack.stackAlerts.threshold.ui.selectIndex": "インデックスを選択してください", - "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "閉じる", - "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "* で検索クエリの範囲を広げます。", - "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "インデックス", - "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "インデックス", - "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "クエリを実行するインデックス", - "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "時間フィールド", "xpack.triggersActionsUI.sections.alertAdd.indexControls.timeFieldOptionLabel": "フィールドを選択", - "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.dataDoesNotExistTextMessage": "時間範囲とフィルターが正しいことを確認してください。", - "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.noDataTitle": "このクエリに一致するデータはありません", - "xpack.stackAlerts.threshold.ui.visualization.unableToLoadVisualizationMessage": "ビジュアライゼーションを読み込めません", "xpack.triggersActionsUI.sections.alertDetails.alertInstances.disabledAlert": "このアラートは無効になっていて再表示できません。[↑ を無効にする]を切り替えてアクティブにします。", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration": "期間", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance": "インスタンス", @@ -20315,6 +19992,289 @@ "xpack.triggersActionsUI.timeUnits.secondLabel": "{timeValue, plural, one {秒} other {秒}}", "xpack.triggersActionsUI.typeRegistry.get.missingActionTypeErrorMessage": "オブジェクトタイプ「{id}」は登録されていません。", "xpack.triggersActionsUI.typeRegistry.register.duplicateObjectTypeErrorMessage": "オブジェクトタイプ「{id}」は既に登録されています。", + "xpack.transform.actionDeleteTransform.bulkDeleteDestinationIndexTitle": "ディスティネーションインデックスの削除", + "xpack.transform.actionDeleteTransform.bulkDeleteDestIndexPatternTitle": "ディスティネーションインデックスパターンの削除", + "xpack.transform.actionDeleteTransform.deleteDestinationIndexTitle": "ディスティネーションインデックス{destinationIndex}の削除", + "xpack.transform.actionDeleteTransform.deleteDestIndexPatternTitle": "インデックスパターン{destinationIndex}の削除", + "xpack.transform.agg.popoverForm.aggLabel": "集約", + "xpack.transform.agg.popoverForm.aggNameAlreadyUsedError": "別の集約で既に同じ名前が使用されています。", + "xpack.transform.agg.popoverForm.aggNameInvalidCharError": "無効な名前です。「[」、「]」「>」は使用できず、名前の始めと終わりにはスペースを使用できません。", + "xpack.transform.agg.popoverForm.fieldLabel": "フィールド", + "xpack.transform.agg.popoverForm.filerAgg.range.greaterThanLabel": "より大きい", + "xpack.transform.agg.popoverForm.filerAgg.range.lessThanLabel": "より小さい", + "xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions": "候補を取得できません", + "xpack.transform.agg.popoverForm.filerAgg.term.valueLabel": "値", + "xpack.transform.agg.popoverForm.filerAggLabel": "フィルタークエリ", + "xpack.transform.agg.popoverForm.nameLabel": "集約名", + "xpack.transform.agg.popoverForm.percentsLabel": "パーセント", + "xpack.transform.agg.popoverForm.submitButtonLabel": "適用", + "xpack.transform.agg.popoverForm.unsupportedAggregationHelpText": "このフォームでは集約名のみを編集できます。詳細エディターを使用して、集約の他の部分を編集してください。", + "xpack.transform.aggLabelForm.deleteItemAriaLabel": "アイテムを削除", + "xpack.transform.aggLabelForm.editAggAriaLabel": "集約を編集", + "xpack.transform.app.checkingPrivilegesDescription": "権限を確認中…", + "xpack.transform.app.checkingPrivilegesErrorMessage": "サーバーからユーザー特権を取得中にエラーが発生。", + "xpack.transform.app.deniedPrivilegeDescription": "Transforms のこのセクションを使用するには、{privilegesCount, plural, one {このクラスター特権} other {これらのクラスター特権}}が必要です: {missingPrivileges}。", + "xpack.transform.app.deniedPrivilegeTitle": "クラスター特権が足りません", + "xpack.transform.appName": "データフレームジョブ", + "xpack.transform.appTitle": "変換", + "xpack.transform.capability.noPermission.createTransformTooltip": "データフレーム変換を作成するパーミッションがありません。", + "xpack.transform.capability.noPermission.deleteTransformTooltip": "データフレーム変換を削除するパーミッションがありません。", + "xpack.transform.capability.noPermission.startOrStopTransformTooltip": "データフレーム変換を開始・停止するパーミッションがありません。", + "xpack.transform.capability.pleaseContactAdministratorTooltip": "{message} 管理者にお問い合わせください。", + "xpack.transform.clone.errorPromptText": "ソースインデックスパターンが存在するかどうかを確認するときにエラーが発生しました", + "xpack.transform.clone.errorPromptTitle": "変換構成の取得中にエラーが発生しました。", + "xpack.transform.clone.fetchErrorPromptText": "KibanaインデックスパターンIDを取得できませんでした。", + "xpack.transform.clone.noIndexPatternErrorPromptText": "変換を複製できません{indexPattern}のインデックスパターンが存在しません。", + "xpack.transform.cloneTransform.breadcrumbTitle": "クローン変換", + "xpack.transform.createTransform.breadcrumbTitle": "変換の作成", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "ディスティネーションインデックス{destinationIndex}の削除中にエラーが発生しました", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage": "インデックスパターン{destinationIndex}の削除中にエラーが発生しました", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage": "インデックスパターン{destinationIndex}を削除する要求が確認されました。", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage": "ディスティネーションインデックス{destinationIndex}を削除する要求が確認されました。", + "xpack.transform.deleteTransform.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage": "インデックスパターン{indexPattern}が存在するかどうかを確認するときにエラーが発生しました。{error}", + "xpack.transform.description": "説明", + "xpack.transform.groupby.popoverForm.aggLabel": "集約", + "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "別のグループ分けの構成が既にこの名前を使用しています。", + "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "無効な名前です。「[」、「]」「>」は使用できず、名前の始めと終わりにはスペースを使用できません。", + "xpack.transform.groupBy.popoverForm.fieldLabel": "フィールド", + "xpack.transform.groupBy.popoverForm.intervalError": "無効な間隔。", + "xpack.transform.groupBy.popoverForm.intervalLabel": "間隔", + "xpack.transform.groupBy.popoverForm.intervalPercents": "パーセンタイルをコンマで区切って列記します。", + "xpack.transform.groupBy.popoverForm.nameLabel": "グループ分け名", + "xpack.transform.groupBy.popoverForm.submitButtonLabel": "適用", + "xpack.transform.groupBy.popoverForm.unsupportedGroupByHelpText": "このフォームでは group_by 名のみを編集できます。詳細エディターを使用して、group_by 構成の他の部分を編集してください。", + "xpack.transform.groupByLabelForm.deleteItemAriaLabel": "アイテムを削除", + "xpack.transform.groupByLabelForm.editIntervalAriaLabel": "間隔を編集", + "xpack.transform.home.breadcrumbTitle": "データフレームジョブ", + "xpack.transform.indexPreview.copyClipboardTooltip": "インデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。", + "xpack.transform.licenseCheckErrorMessage": "ライセンス確認失敗", + "xpack.transform.list.emptyPromptButtonText": "初めての変換を作成してみましょう。", + "xpack.transform.list.emptyPromptTitle": "変換が見つかりません", + "xpack.transform.list.errorPromptTitle": "変換リストの取得中にエラーが発生しました。", + "xpack.transform.mode": "モード", + "xpack.transform.modeFilter": "モード", + "xpack.transform.models.transformService.allOtherRequestsCancelledDescription": "他のすべてのリクエストはキャンセルされました。", + "xpack.transform.models.transformService.requestToActionTimedOutErrorMessage": "「{id}」を{action}するリクエストがタイムアウトしました。{extra}", + "xpack.transform.multiTransformActionsMenu.managementActionsAriaLabel": "管理アクション", + "xpack.transform.multiTransformActionsMenu.transformsCount": "{count} 件の{count, plural, one {変換} other {変換}}を選択済み", + "xpack.transform.newTransform.chooseSourceTitle": "ソースの選択", + "xpack.transform.newTransform.newTransformTitle": "新規変換", + "xpack.transform.newTransform.searchSelection.notFoundLabel": "一致するインデックスまたは保存検索が見つかりませんでした。", + "xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "インデックスパターン", + "xpack.transform.newTransform.searchSelection.savedObjectType.search": "保存検索", + "xpack.transform.pivotPreview.copyClipboardTooltip": "ピボットプレビューの開発コンソールステートメントをクリップボードにコピーします。", + "xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "group-by フィールドと集約を 1 つ以上選んでください。", + "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "プレビューリクエストはデータを返しませんでした。オプションのクエリがデータを返し、グループ分け基準により使用されるフィールドと集約フィールドに値が存在することを確認してください。", + "xpack.transform.pivotPreview.PivotPreviewTitle": "ピボットプレビューを変換", + "xpack.transform.progress": "進捗", + "xpack.transform.statsBar.batchTransformsLabel": "一斉", + "xpack.transform.statsBar.continuousTransformsLabel": "連続", + "xpack.transform.statsBar.failedTransformsLabel": "失敗", + "xpack.transform.statsBar.startedTransformsLabel": "開始済み", + "xpack.transform.statsBar.totalTransformsLabel": "変換合計", + "xpack.transform.status": "ステータス", + "xpack.transform.statusFilter": "ステータス", + "xpack.transform.stepCreateForm.continuousModeLabel": "連続モード", + "xpack.transform.stepCreateForm.copyTransformConfigToClipboardButton": "クリップボードにコピー", + "xpack.transform.stepCreateForm.copyTransformConfigToClipboardDescription": "ジョブを作成する Kibana 開発コンソールのコマンドをクリップボードにコピーします。", + "xpack.transform.stepCreateForm.createAndStartTransformButton": "作成して開始", + "xpack.transform.stepCreateForm.createAndStartTransformDescription": "変換を作成して開始します。変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。変換の開始後、変換の閲覧を続けるオプションが提供されます。", + "xpack.transform.stepCreateForm.createIndexPatternErrorMessage": "Kibana インデックスパターン {indexPatternName} の作成中にエラーが発生しました:", + "xpack.transform.stepCreateForm.createIndexPatternLabel": "インデックスパターンを作成", + "xpack.transform.stepCreateForm.createIndexPatternSuccessMessage": "Kibana インデックスパターン {indexPatternName} が作成されました", + "xpack.transform.stepCreateForm.createTransformButton": "作成", + "xpack.transform.stepCreateForm.createTransformDescription": "変換を開始せずに作成します。変換は後程変換リストに戻って開始できます。", + "xpack.transform.stepCreateForm.createTransformErrorMessage": "変換 {transformId} の取得中にエラーが発生しました。", + "xpack.transform.stepCreateForm.createTransformSuccessMessage": "変換 {transformId} の作成リクエストが受け付けられました。", + "xpack.transform.stepCreateForm.creatingIndexPatternMessage": "Kibana インデックスパターンを作成中…", + "xpack.transform.stepCreateForm.discoverCardDescription": "ディスカバリでデータフレームピボットを閲覧します。", + "xpack.transform.stepCreateForm.discoverCardTitle": "ディスカバー", + "xpack.transform.stepCreateForm.duplicateIndexPatternErrorMessage": "Kibana インデックスパターン {indexPatternName} の作成中にエラーが発生しました:インデックスパターンが既に存在します。", + "xpack.transform.stepCreateForm.progressErrorMessage": "進捗パーセンテージの取得中にエラーが発生しました:", + "xpack.transform.stepCreateForm.progressTitle": "進捗", + "xpack.transform.stepCreateForm.startTransformButton": "開始", + "xpack.transform.stepCreateForm.startTransformDescription": "変換を開始します。変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。変換の開始後、変換の閲覧を続けるオプションが提供されます。", + "xpack.transform.stepCreateForm.startTransformErrorMessage": "変換 {transformId} の開始中にエラーが発生しました。", + "xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage": "変換開始要求の呼び出し中にエラーが発生しました。", + "xpack.transform.stepCreateForm.startTransformSuccessMessage": "変換 {transformId} の開始リクエストが受け付けられました。", + "xpack.transform.stepCreateForm.transformListCardDescription": "データフレームジョブの管理ページに戻ります。", + "xpack.transform.stepCreateForm.transformListCardTitle": "データフレームジョブ", + "xpack.transform.stepDefineForm.addSubAggregationPlaceholder": "下位集約を追加...", + "xpack.transform.stepDefineForm.advancedEditorApplyButtonText": "変更を適用", + "xpack.transform.stepDefineForm.advancedEditorAriaLabel": "高度なピボットエディター", + "xpack.transform.stepDefineForm.advancedEditorHelpText": "詳細エディターでは、変換のピボット構成を編集できます。", + "xpack.transform.stepDefineForm.advancedEditorHelpTextLink": "使用可能なオプションの詳細を確認してください。", + "xpack.transform.stepDefineForm.advancedEditorLabel": "ピボット構成オブジェクト", + "xpack.transform.stepDefineForm.advancedEditorSourceConfigSwitchLabel": "JSONクエリを編集", + "xpack.transform.stepDefineForm.advancedEditorSwitchLabel": "JSON構成を編集", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalBodyText": "詳細エディターの変更は適用されませんでした。詳細エディターを無効にすると、編集内容が失われます。", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalCancelButtonText": "キャンセル", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalConfirmButtonText": "詳細エディターを無効にする", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalTitle": "適用されていない変更", + "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "変更を適用", + "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "クエリの詳細エディター", + "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高度なエディターでは、変換構成のソースクエリ句を編集できます。", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "クエリバーに戻すと、編集内容が失われます。", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "クエリバーに切り替え", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "編集内容は失われます", + "xpack.transform.stepDefineForm.aggExistsErrorMessage": "「{aggName}」という名前の集約構成は既に存在します。", + "xpack.transform.stepDefineForm.aggregationsLabel": "アグリゲーション(集計)", + "xpack.transform.stepDefineForm.aggregationsPlaceholder": "集約を追加…", + "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "「{aggName}」という名前のグループ分け構成は既に存在します。", + "xpack.transform.stepDefineForm.groupByLabel": "グループ分けの条件", + "xpack.transform.stepDefineForm.groupByPlaceholder": "グループ分けの条件フィールドを追加…", + "xpack.transform.stepDefineForm.indexPatternLabel": "インデックスパターン", + "xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar": "無効なクエリ:{errorMessage}", + "xpack.transform.stepDefineForm.maxSubAggsLevelsLimitMessage": "フォームで追加できる下位集約の最大レベル数に達しました。別のレベルを追加する場合は、JSON構成を編集してください。", + "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "「{aggListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", + "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "「{aggNameCheck}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", + "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "「{groupByListName}」とネスティングの矛盾があるため、構成「{aggName}」を追加できませんでした。", + "xpack.transform.stepDefineForm.queryPlaceholderKql": "例: {example}", + "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例: {example}", + "xpack.transform.stepDefineForm.savedSearchLabel": "保存検索", + "xpack.transform.stepDefineSummary.aggregationsLabel": "アグリゲーション(集計)", + "xpack.transform.stepDefineSummary.groupByLabel": "グループ分けの条件", + "xpack.transform.stepDefineSummary.indexPatternLabel": "インデックスパターン", + "xpack.transform.stepDefineSummary.queryCodeBlockLabel": "クエリ", + "xpack.transform.stepDefineSummary.queryLabel": "クエリ", + "xpack.transform.stepDefineSummary.savedSearchLabel": "保存検索", + "xpack.transform.stepDetailsForm.advancedSettingsAccordionButtonContent": "高度な設定", + "xpack.transform.stepDetailsForm.continuousModeAriaLabel": "遅延を選択してください。", + "xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText": "新しいドキュメントを特定するために使用できる日付フィールドを選択してください。", + "xpack.transform.stepDetailsForm.continuousModeDateFieldLabel": "日付フィールド", + "xpack.transform.stepDetailsForm.continuousModeDelayError": "無効な遅延フォーマット", + "xpack.transform.stepDetailsForm.continuousModeDelayHelpText": "現在の時刻と最新のインプットデータ時刻の間の遅延です。", + "xpack.transform.stepDetailsForm.continuousModeDelayLabel": "遅延", + "xpack.transform.stepDetailsForm.continuousModeError": "日付フィールドがないインデックスでは、連続モードを使用できません。", + "xpack.transform.stepDetailsForm.destinationIndexHelpText": "この名前のインデックスが既に存在します。この変換を実行すると、デスティネーションインデックスが変更されます。", + "xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel": "固有の宛先インデックス名を選択してください。", + "xpack.transform.stepDetailsForm.destinationIndexInvalidError": "無効なデスティネーションインデックス名。", + "xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink": "インデックス名の制限に関する詳細。", + "xpack.transform.stepDetailsForm.destinationIndexLabel": "デスティネーションインデックス", + "xpack.transform.stepDetailsForm.editFlyoutFormFrequencyPlaceholderText": "デフォルト:{defaultValue}", + "xpack.transform.stepDetailsForm.editFlyoutFormMaxPageSearchSizePlaceholderText": "デフォルト:{defaultValue}", + "xpack.transform.stepDetailsForm.errorGettingIndexNames": "既存のインデックス名の取得中にエラーが発生しました:", + "xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました:", + "xpack.transform.stepDetailsForm.errorGettingTransformList": "既存の変換 ID の取得中にエラーが発生しました:", + "xpack.transform.stepDetailsForm.errorGettingTransformPreview": "変換プレビューの取得中にエラーが発生しました。", + "xpack.transform.stepDetailsForm.frequencyAriaLabel": "頻度を選択してください。", + "xpack.transform.stepDetailsForm.frequencyError": "無効な頻度形式", + "xpack.transform.stepDetailsForm.frequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。", + "xpack.transform.stepDetailsForm.frequencyLabel": "頻度", + "xpack.transform.stepDetailsForm.indexPatternTimeFieldHelpText": "グローバル時間フィルターで使用するためのプライマリ時間フィールドを選択してください。", + "xpack.transform.stepDetailsForm.indexPatternTimeFieldLabel": "時間フィールド", + "xpack.transform.stepDetailsForm.indexPatternTitleError": "このタイトルのインデックスパターンが既に存在します。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeAriaLabel": "最大ページ検索サイズを選択してください。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeError": "max_page_search_sizeは10~10000の範囲の数値でなければなりません。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeLabel": "最大ページ検索サイズ", + "xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "時間フィルターを使用しない", + "xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "オプションの変換の説明を選択してください。", + "xpack.transform.stepDetailsForm.transformDescriptionLabel": "変換の説明", + "xpack.transform.stepDetailsForm.transformDescriptionPlaceholderText": "説明(オプション)", + "xpack.transform.stepDetailsForm.transformIdExistsError": "この ID の変換が既に存在します。", + "xpack.transform.stepDetailsForm.transformIdInputAriaLabel": "固有のジョブ ID を選択してください。", + "xpack.transform.stepDetailsForm.transformIdInvalidError": "小文字のアルファベットと数字 (a-z と 0-9)、ハイフンまたはアンダーラインのみ使用でき、最初と最後を英数字にする必要があります。", + "xpack.transform.stepDetailsForm.transformIdLabel": "ジョブ ID", + "xpack.transform.stepDetailsSummary.advancedSettingsAccordionButtonContent": "高度な設定", + "xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "連続モード日付フィールド", + "xpack.transform.stepDetailsSummary.createIndexPatternMessage": "このジョブの Kibana インデックスパターンが作成されます。", + "xpack.transform.stepDetailsSummary.destinationIndexLabel": "デスティネーションインデックス", + "xpack.transform.stepDetailsSummary.frequencyLabel": "頻度", + "xpack.transform.stepDetailsSummary.indexPatternTimeFieldLabel": "Kibanaインデックスパターン時間フィールド", + "xpack.transform.stepDetailsSummary.maxPageSearchSizeLabel": "最大ページ検索サイズ", + "xpack.transform.stepDetailsSummary.transformDescriptionLabel": "変換の説明", + "xpack.transform.stepDetailsSummary.transformIdLabel": "ジョブ ID", + "xpack.transform.tableActionLabel": "アクション", + "xpack.transform.toastText.closeModalButtonText": "閉じる", + "xpack.transform.toastText.modalTitle": "詳細を入力", + "xpack.transform.toastText.openModalButtonText": "詳細を表示", + "xpack.transform.transformForm.sizeNotationPlaceholder": "例: {example1}、{example2}、{example3}、{example4}", + "xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage": "{count}個のディスティネーションインデックス{count, plural, one {パターン} other {パターン}}を正常に削除しました。", + "xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage": "{count}個のディスティネーション{count, plural, one {インデックス} other {インデックス}}を正常に削除しました。", + "xpack.transform.transformList.bulkDeleteModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を削除", + "xpack.transform.transformList.bulkDeleteTransformSuccessMessage": "{count} {count, plural, one {個の変換} other {個の変換}}を正常に削除しました。", + "xpack.transform.transformList.bulkStartModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を開始", + "xpack.transform.transformList.cloneActionNameText": "クローンを作成", + "xpack.transform.transformList.completeBatchTransformBulkActionToolTip": "1 つまたは複数の変換が完了済みの一斉変換で、再度開始できません。", + "xpack.transform.transformList.completeBatchTransformToolTip": "{transformId} は完了済みの一斉変換で、再度開始できません。", + "xpack.transform.transformList.createTransformButton": "変換の作成", + "xpack.transform.transformList.deleteActionDisabledToolTipContent": "削除するにはデータフレームジョブを停止してください。", + "xpack.transform.transformList.deleteActionNameText": "削除", + "xpack.transform.transformList.deleteBulkActionDisabledToolTipContent": "削除するには、選択された変換のうちの 1 つまたは複数を停止する必要があります。", + "xpack.transform.transformList.deleteModalCancelButton": "キャンセル", + "xpack.transform.transformList.deleteModalDeleteButton": "削除", + "xpack.transform.transformList.deleteModalTitle": "{transformId}を削除しますか?", + "xpack.transform.transformList.deleteTransformErrorMessage": "変換 {transformId} の削除中にエラーが発生しました", + "xpack.transform.transformList.deleteTransformGenericErrorMessage": "変換を削除するための API エンドポイントの呼び出し中にエラーが発生しました。", + "xpack.transform.transformList.deleteTransformSuccessMessage": "変換 {transformId} の削除リクエストが受け付けられました。", + "xpack.transform.transformList.editActionNameText": "編集", + "xpack.transform.transformList.editFlyoutCalloutDocs": "ドキュメントを表示", + "xpack.transform.transformList.editFlyoutCalloutText": "このフォームでは、変換を更新できます。更新できるプロパティのリストは、変換を作成するときに定義できるリストのサブセットです。", + "xpack.transform.transformList.editFlyoutCancelButtonText": "キャンセル", + "xpack.transform.transformList.editFlyoutFormAdvancedSettingsButtonContent": "高度な設定", + "xpack.transform.transformList.editFlyoutFormDescriptionLabel": "説明", + "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "ディスティネーション構成", + "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "デスティネーションインデックス", + "xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "パイプライン", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "スロットリングを有効にするには、毎秒入力するドキュメントの上限を設定します。", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "毎秒あたりのドキュメント", + "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。", + "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "頻度", + "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "頻度値が無効です。", + "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "デフォルト:{defaultValue}", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大ページ検索サイズ", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "デフォルト:{defaultValue}", + "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "値は1以上の整数でなければなりません。", + "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "値は10~10000の範囲の整数でなければなりません。", + "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必須フィールド。", + "xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage": "値は文字列型でなければなりません。", + "xpack.transform.transformList.editFlyoutTitle": "{transformId}を編集", + "xpack.transform.transformList.editFlyoutUpdateButtonText": "更新", + "xpack.transform.transformList.editTransformGenericErrorMessage": "変換を削除するためのAPIエンドポイントの呼び出し中にエラーが発生しました。", + "xpack.transform.transformList.editTransformSuccessMessage": "変換{transformId}が更新されました。", + "xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage": "ユーザーがディスティネーションインデックスを削除できるかどうかを確認するときにエラーが発生しました。", + "xpack.transform.transformList.refreshButtonLabel": "更新", + "xpack.transform.transformList.rowCollapse": "{transformId} の詳細を非表示", + "xpack.transform.transformList.rowExpand": "{transformId} の詳細を表示", + "xpack.transform.transformList.showDetailsColumn.screenReaderDescription": "このカラムには変換ごとの詳細を示すクリック可能なコントロールが含まれます", + "xpack.transform.transformList.startActionNameText": "開始", + "xpack.transform.transformList.startedTransformBulkToolTip": "1 つまたは複数の変換が既に開始済みです。", + "xpack.transform.transformList.startedTransformToolTip": "{transformId} は既に開始済みです。", + "xpack.transform.transformList.startModalBody": "変換は、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は変換を停止してください。", + "xpack.transform.transformList.startModalCancelButton": "キャンセル", + "xpack.transform.transformList.startModalStartButton": "開始", + "xpack.transform.transformList.startModalTitle": "{transformId}を開始しますか?", + "xpack.transform.transformList.startTransformErrorMessage": "変換 {transformId} の開始中にエラーが発生しました", + "xpack.transform.transformList.startTransformSuccessMessage": "変換 {transformId} の開始リクエストが受け付けられました。", + "xpack.transform.transformList.stopActionNameText": "終了", + "xpack.transform.transformList.stoppedTransformBulkToolTip": "1 つまたは複数の変換が既に開始済みです。", + "xpack.transform.transformList.stoppedTransformToolTip": "{transformId} は既に停止済みです。", + "xpack.transform.transformList.stopTransformErrorMessage": "データフレーム変換 {transformId} の停止中にエラーが発生しました", + "xpack.transform.transformList.stopTransformResponseSchemaErrorMessage": "変換停止要求の呼び出し中にエラーが発生しました。", + "xpack.transform.transformList.stopTransformSuccessMessage": "データフレーム変換 {transformId} の停止リクエストが受け付けられました。", + "xpack.transform.transformList.transformDescription": "変換を使用して、集約されたインデックスまたはエンティティ中心のインデックスに、既存のElasticsearchインデックスをインデックスします。", + "xpack.transform.transformList.transformDetails.messagesPane.errorMessage": "メッセージを読み込めませんでした", + "xpack.transform.transformList.transformDetails.messagesPane.messageLabel": "メッセージ", + "xpack.transform.transformList.transformDetails.messagesPane.nodeLabel": "ノード", + "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "時間", + "xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel": "詳細", + "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "メッセージ", + "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "プレビュー", + "xpack.transform.transformList.transformDetails.tabs.transformStatsLabel": "統計", + "xpack.transform.transformList.transformDocsLinkText": "変換ドキュメント", + "xpack.transform.transformList.transformTitle": "データフレームジョブ", + "xpack.transform.transformsDescription": "変換を使用して、集約されたインデックスまたはエンティティ中心のインデックスに、既存のElasticsearchインデックスをインデックスします。", + "xpack.transform.transformsTitle": "変換", + "xpack.transform.transformsWizard.cloneTransformTitle": "クローン変換", + "xpack.transform.transformsWizard.createTransformTitle": "変換の作成", + "xpack.transform.transformsWizard.stepConfigurationTitle": "構成", + "xpack.transform.transformsWizard.stepCreateTitle": "作成", + "xpack.transform.transformsWizard.stepDetailsTitle": "ジョブの詳細", + "xpack.transform.transformsWizard.transformDocsLinkText": "変換ドキュメント", + "xpack.transform.wizard.nextStepButton": "次へ", + "xpack.transform.wizard.previousStepButton": "前へ", "xpack.uiActionsEnhanced.components.actionWizard.betaActionLabel": "ベータ", "xpack.uiActionsEnhanced.components.actionWizard.betaActionTooltip": "このアクションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャルGA機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャルGA機能のSLAが適用されません。バグを報告したり、その他のフィードバックを提供したりして、当社を支援してください。", "xpack.uiActionsEnhanced.components.actionWizard.changeButton": "変更", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d069d43de7404a..d1ab2518c9ecde 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -240,7 +240,6 @@ "apmOss.tutorial.startServer.title": "启动 APM Server", "apmOss.tutorial.windowsServerInstructions.textPost": "注意:如果您的系统禁用了脚本执行,则需要为当前会话设置执行策略,以允许脚本运行。示例:{command}。", "apmOss.tutorial.windowsServerInstructions.textPre": "1.从[下载页面]({downloadPageLink})下载 APM Server Windows zip 文件。\n2.将 zip 文件的内容解压缩到 {zipFileExtractFolder}。\n3.将 {apmServerDirectory} 目录重命名为 `APM-Server`。\n4.以管理员身份打开 PowerShell 提示符(右键单击 PowerShell 图标,然后选择**以管理员身份运行**)。如果您正在运行 Windows XP,您可能需要下载并安装 PowerShell。\n5.从 PowerShell 提示符处,运行以下命令以将 APM Server 安装为 Windows 服务:", - "charts.advancedSettings.visualization.colorMappingText": "将值映射到可视化内的指定颜色", "charts.advancedSettings.visualization.colorMappingTitle": "颜色映射", "charts.colormaps.bluesText": "蓝色", "charts.colormaps.greensText": "绿色", @@ -4964,7 +4963,6 @@ "xpack.apm.metadataTable.section.urlLabel": "URL", "xpack.apm.metadataTable.section.userAgentLabel": "用户代理", "xpack.apm.metadataTable.section.userLabel": "用户", - "xpack.apm.metrics.plot.noDataLabel": "此时间范围内没有数据。", "xpack.apm.metrics.transactionChart.machineLearningLabel": "Machine Learning", "xpack.apm.metrics.transactionChart.machineLearningTooltip": "环绕平均持续时间的流显示预期边界。对 ≥ 75 的异常分数显示标注。", "xpack.apm.metrics.transactionChart.machineLearningTooltip.withKuery": "使用搜索栏筛选时,Machine Learning 结果处于隐藏状态", @@ -5083,7 +5081,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "每分钟事务数", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "tpm", "xpack.apm.servicesTable.UpgradeAssistantLink": "通过访问 Kibana 升级助手来了解详情", - "xpack.apm.serviceVersion": "服务版本", "xpack.apm.settings.agentConfig": "代理配置", "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "我们在以前的集成中发现 APM 应用中不再使用的旧版 Machine Learning 作业", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "复查作业", @@ -5189,9 +5186,7 @@ "xpack.apm.transactionActionMenu.actionsButtonLabel": "操作", "xpack.apm.transactionActionMenu.container.subtitle": "查看此容器的日志和指标以获取进一步详情。", "xpack.apm.transactionActionMenu.container.title": "容器详情", - "xpack.apm.transactionActionMenu.customLink.popover.title": "定制链接", "xpack.apm.transactionActionMenu.customLink.section": "定制链接", - "xpack.apm.transactionActionMenu.customLink.seeMore": "查看更多内容", "xpack.apm.transactionActionMenu.customLink.subtitle": "链接将在新窗口打开。", "xpack.apm.transactionActionMenu.host.subtitle": "查看主机日志和指标以获取进一步详情。", "xpack.apm.transactionActionMenu.host.title": "主机详情", @@ -5292,7 +5287,6 @@ "xpack.apm.ux.title": "用户体验", "xpack.apm.ux.url.hitEnter.include": "单击 {icon} 可包括与 {searchValue} 匹配的所有 URL", "xpack.apm.ux.visitorBreakdown.noData": "无数据。", - "xpack.apm.version": "版本", "xpack.apm.waterfall.exceedsMax": "此跟踪中的项目数超过显示的项目数", "xpack.beatsManagement.beat.actionSectionTypeLabel": "类型:{beatType}。", "xpack.beatsManagement.beat.actionSectionVersionLabel": "版本:{beatVersion}。", @@ -7182,17 +7176,13 @@ "xpack.fleet.agentDetails.agentVersionLabel": "代理版本", "xpack.fleet.agentDetails.hostIdLabel": "代理 ID", "xpack.fleet.agentDetails.hostNameLabel": "主机名", - "xpack.fleet.agentDetails.localMetadataSectionSubtitle": "本地元数据", - "xpack.fleet.agentDetails.metadataSectionTitle": "元数据", "xpack.fleet.agentDetails.platformLabel": "平台", "xpack.fleet.agentDetails.policyLabel": "策略", "xpack.fleet.agentDetails.releaseLabel": "代理发行版", "xpack.fleet.agentDetails.statusLabel": "状态", - "xpack.fleet.agentDetails.subTabs.activityLogTab": "活动日志", "xpack.fleet.agentDetails.subTabs.detailsTab": "代理详情", "xpack.fleet.agentDetails.unexceptedErrorTitle": "加载代理时出错", "xpack.fleet.agentDetails.upgradeAvailableTooltip": "升级可用", - "xpack.fleet.agentDetails.userProvidedMetadataSectionSubtitle": "用户提供的元数据", "xpack.fleet.agentDetails.versionLabel": "代理版本", "xpack.fleet.agentDetails.viewAgentListTitle": "查看所有代理", "xpack.fleet.agentEnrollment.agentDescription": "将 Elastic 代理添加到您的主机,以收集数据并将其发送到 Elastic Stack。", @@ -7220,32 +7210,6 @@ "xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle": "注册并启动 Elastic 代理", "xpack.fleet.agentEnrollment.stepRunAgentDescription": "从代理目录运行此命令,以安装、注册并启动 Elastic 代理。您可以重复使用此命令在多个主机上设置代理。需要管理员权限。", "xpack.fleet.agentEnrollment.stepRunAgentTitle": "启动代理", - "xpack.fleet.agentEventsList.collapseDetailsAriaLabel": "隐藏详情", - "xpack.fleet.agentEventsList.expandDetailsAriaLabel": "显示详情", - "xpack.fleet.agentEventsList.messageColumnTitle": "消息", - "xpack.fleet.agentEventsList.messageDetailsTitle": "消息", - "xpack.fleet.agentEventsList.payloadDetailsTitle": "负载", - "xpack.fleet.agentEventsList.refreshButton": "刷新", - "xpack.fleet.agentEventsList.searchPlaceholderText": "搜索活动日志", - "xpack.fleet.agentEventsList.subtypeColumnTitle": "子类型", - "xpack.fleet.agentEventsList.timestampColumnTitle": "时间戳", - "xpack.fleet.agentEventsList.typeColumnTitle": "类型", - "xpack.fleet.agentEventSubtype.acknowledgedLabel": "已确认", - "xpack.fleet.agentEventSubtype.dataDumpLabel": "数据转储", - "xpack.fleet.agentEventSubtype.degradedLabel": "已降级", - "xpack.fleet.agentEventSubtype.failedLabel": "失败", - "xpack.fleet.agentEventSubtype.inProgressLabel": "进行中", - "xpack.fleet.agentEventSubtype.policyLabel": "策略", - "xpack.fleet.agentEventSubtype.runningLabel": "正在运行", - "xpack.fleet.agentEventSubtype.startingLabel": "正在启动", - "xpack.fleet.agentEventSubtype.stoppedLabel": "已停止", - "xpack.fleet.agentEventSubtype.stoppingLabel": "正在停止", - "xpack.fleet.agentEventSubtype.unknownLabel": "未知", - "xpack.fleet.agentEventSubtype.updatingLabel": "正在更新", - "xpack.fleet.agentEventType.actionLabel": "操作", - "xpack.fleet.agentEventType.actionResultLabel": "操作结果", - "xpack.fleet.agentEventType.errorLabel": "错误", - "xpack.fleet.agentEventType.stateLabel": "状态", "xpack.fleet.agentHealth.checkInTooltipText": "上次签入时间 {lastCheckIn}", "xpack.fleet.agentHealth.degradedStatusText": "已降级", "xpack.fleet.agentHealth.enrollingStatusText": "正在注册", @@ -7593,10 +7557,6 @@ "xpack.fleet.invalidLicenseTitle": "已过期许可证", "xpack.fleet.listTabs.agentTitle": "代理", "xpack.fleet.listTabs.enrollmentTokensTitle": "注册令牌", - "xpack.fleet.metadataForm.addButton": "+ 添加元数据", - "xpack.fleet.metadataForm.keyLabel": "键", - "xpack.fleet.metadataForm.submitButtonText": "添加", - "xpack.fleet.metadataForm.valueLabel": "值", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "命名空间包含无效字符", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "命名空间必须小写", "xpack.fleet.namespaceValidation.requiredErrorMessage": "“命名空间”必填", @@ -19513,307 +19473,85 @@ "xpack.stackAlerts.indexThreshold.actionVariableContextThresholdLabel": "用作阈值的值数组;“between”和“notBetween”需要两个值,其他则需要一个值。", "xpack.stackAlerts.indexThreshold.actionVariableContextTitleLabel": "告警的预构造标题。", "xpack.stackAlerts.indexThreshold.actionVariableContextValueLabel": "超过阈值的值。", - "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggField]:当 [aggType] 为“{aggType}”时必须有值", "xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription": "告警 {name} 组 {group} 值 {value} 在 {window} 于 {date}超过了阈值 {function}", "xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "告警 {name} 组 {group} 超过了阈值", "xpack.stackAlerts.indexThreshold.alertTypeTitle": "索引阈值", + "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}", + "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素", + "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "选择边界名称", + "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "可人工读取的边界名称(可选)", + "xpack.stackAlerts.geoThreshold.delayOffset": "已延迟的评估偏移", + "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "评估延迟周期内的告警,以针对数据延迟进行调整", + "xpack.stackAlerts.geoThreshold.entityByLabel": "方式", + "xpack.stackAlerts.geoThreshold.entityIndexLabel": "索引", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "“边界地理”字段必填。", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "“边界索引模式标题”必填。", + "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "“边界类型”必填。", + "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "“日期”字段必填。", + "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "“实体”必填。", + "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "“地理”字段必填。", + "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "“索引模式”必填。", + "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "“跟踪事件”必填。", + "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", + "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空间字段", + "xpack.stackAlerts.geoThreshold.indexLabel": "索引", + "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "索引模式", + "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "选择索引模式", + "xpack.stackAlerts.geoThreshold.name.trackingThreshold": "跟踪阈值", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "创建索引模式", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "您将需要 ", + "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " (包含地理空间字段)。", + "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。", + "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "没有任何地理空间数据集? ", + "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "找不到任何具有地理空间字段的索引模式", + "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "选择边界:", + "xpack.stackAlerts.geoThreshold.selectEntity": "选择实体", + "xpack.stackAlerts.geoThreshold.selectGeoLabel": "选择地理字段", + "xpack.stackAlerts.geoThreshold.selectIndex": "定义条件", + "xpack.stackAlerts.geoThreshold.selectLabel": "选择地理字段", + "xpack.stackAlerts.geoThreshold.selectOffset": "选择偏移(可选)", + "xpack.stackAlerts.geoThreshold.selectTimeLabel": "选择时间字段", + "xpack.stackAlerts.geoThreshold.timeFieldLabel": "时间字段", + "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "选择实体字段", + "xpack.stackAlerts.geoThreshold.whenEntityLabel": "当实体", + "xpack.stackAlerts.threshold.ui.validation.error.greaterThenThreshold0Text": "阈值 1 应 > 阈值 0。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredAggFieldText": "聚合字段必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredIndexText": "“索引”必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTermSizedText": "“词大小”必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold0Text": "阈值 0 必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold1Text": "阈值 1 必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeFieldText": "时间字段必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeWindowSizeText": "“时间窗大小”必填。", + "xpack.stackAlerts.threshold.ui.validation.error.requiredTermFieldText": "词字段必填。", + "xpack.stackAlerts.threshold.ui.conditionPrompt": "定义条件", + "xpack.stackAlerts.threshold.ui.visualization.errorLoadingAlertVisualizationTitle": "无法加载告警可视化", + "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "关闭", + "xpack.stackAlerts.threshold.ui.visualization.loadingAlertVisualizationDescription": "正在加载告警可视化……", + "xpack.stackAlerts.threshold.ui.previewAlertVisualizationDescription": "完成表达式以生成预览。", + "xpack.stackAlerts.threshold.ui.selectIndex": "选择索引", + "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "关闭", + "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", + "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "使用 * 可扩大您的查询范围。", + "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "索引", + "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "索引", + "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "要查询的索引", + "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "时间字段", + "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.dataDoesNotExistTextMessage": "确认您的时间范围和筛选正确。", + "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.noDataTitle": "没有数据匹配此查询", + "xpack.stackAlerts.threshold.ui.visualization.unableToLoadVisualizationMessage": "无法加载可视化", + "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggField]:当 [aggType] 为“{aggType}”时必须有值", "xpack.triggersActionsUI.data.coreQueryParams.dateStartGTdateEndErrorMessage": "[dateStart]:晚于 [dateEnd]", "xpack.triggersActionsUI.data.coreQueryParams.formattedFieldErrorMessage": "{fieldName} 的 {formatName} 格式无效:“{fieldValue}”", "xpack.triggersActionsUI.data.coreQueryParams.intervalRequiredErrorMessage": "[interval]:如果 [dateStart] 不等于 [dateEnd],则必须指定", "xpack.triggersActionsUI.data.coreQueryParams.invalidAggTypeErrorMessage": "aggType 无效:“{aggType}”", - "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}", "xpack.triggersActionsUI.data.coreQueryParams.invalidDateErrorMessage": "日期 {date} 无效", "xpack.triggersActionsUI.data.coreQueryParams.invalidDurationErrorMessage": "持续时间无效:“{duration}”", "xpack.triggersActionsUI.data.coreQueryParams.invalidGroupByErrorMessage": "groupBy 无效:“{groupBy}”", "xpack.triggersActionsUI.data.coreQueryParams.invalidTermSizeMaximumErrorMessage": "[termSize]:必须小于或等于 {maxGroups}", - "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素", "xpack.triggersActionsUI.data.coreQueryParams.invalidTimeWindowUnitsErrorMessage": "timeWindowUnit 无效:“{timeWindowUnit}”", "xpack.triggersActionsUI.data.coreQueryParams.maxIntervalsErrorMessage": "时间间隔 {intervals} 的计算数目大于最大值 {maxIntervals}", "xpack.triggersActionsUI.data.coreQueryParams.termFieldRequiredErrorMessage": "[termField]:[groupBy] 为 top 时,termField 为必需", "xpack.triggersActionsUI.data.coreQueryParams.termSizeRequiredErrorMessage": "[termSize]:[groupBy] 为 top 时,termSize 为必需", - "xpack.transform.actionDeleteTransform.bulkDeleteDestinationIndexTitle": "删除目标索引", - "xpack.transform.actionDeleteTransform.bulkDeleteDestIndexPatternTitle": "删除目标索引模式", - "xpack.transform.actionDeleteTransform.deleteDestinationIndexTitle": "删除目标索引 {destinationIndex}", - "xpack.transform.actionDeleteTransform.deleteDestIndexPatternTitle": "删除索引模式 {destinationIndex}", - "xpack.transform.agg.popoverForm.aggLabel": "聚合", - "xpack.transform.agg.popoverForm.aggNameAlreadyUsedError": "其他聚合已使用该名称。", - "xpack.transform.agg.popoverForm.aggNameInvalidCharError": "名称无效。不允许使用字符“[”、“]”和“>”,且名称不得以空格字符开头或结束。", - "xpack.transform.agg.popoverForm.fieldLabel": "字段", - "xpack.transform.agg.popoverForm.filerAgg.range.greaterThanLabel": "大于", - "xpack.transform.agg.popoverForm.filerAgg.range.lessThanLabel": "小于", - "xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions": "无法获取建议", - "xpack.transform.agg.popoverForm.filerAgg.term.valueLabel": "值", - "xpack.transform.agg.popoverForm.filerAggLabel": "筛选查询", - "xpack.transform.agg.popoverForm.nameLabel": "聚合名称", - "xpack.transform.agg.popoverForm.percentsLabel": "百分数", - "xpack.transform.agg.popoverForm.submitButtonLabel": "应用", - "xpack.transform.agg.popoverForm.unsupportedAggregationHelpText": "在此表单中仅可以编辑聚合名称。请使用高级编辑器编辑聚合的其他部分。", - "xpack.transform.aggLabelForm.deleteItemAriaLabel": "删除项", - "xpack.transform.aggLabelForm.editAggAriaLabel": "编辑聚合", - "xpack.transform.app.checkingPrivilegesDescription": "正在检查权限……", - "xpack.transform.app.checkingPrivilegesErrorMessage": "从服务器获取用户权限时出错。", - "xpack.transform.app.deniedPrivilegeDescription": "要使用“转换”的此部分,必须具有{privilegesCount, plural, one {以下集群权限} other {以下集群权限}}:{missingPrivileges}。", - "xpack.transform.app.deniedPrivilegeTitle": "您缺少集群权限", - "xpack.transform.appName": "数据帧作业", - "xpack.transform.appTitle": "转换", - "xpack.transform.capability.noPermission.createTransformTooltip": "您无权创建数据帧转换。", - "xpack.transform.capability.noPermission.deleteTransformTooltip": "您无权删除数据帧转换。", - "xpack.transform.capability.noPermission.startOrStopTransformTooltip": "您无权启动或停止转换。", - "xpack.transform.capability.pleaseContactAdministratorTooltip": "{message}请联系您的管理员。", - "xpack.transform.clone.errorPromptText": "检查源索引模式是否存在时发生错误", - "xpack.transform.clone.errorPromptTitle": "获取转换配置时发生错误。", - "xpack.transform.clone.fetchErrorPromptText": "无法提取 Kibana 索引模式 ID。", - "xpack.transform.clone.noIndexPatternErrorPromptText": "无法克隆转换。对于 {indexPattern},不存在索引模式。", - "xpack.transform.cloneTransform.breadcrumbTitle": "克隆转换", - "xpack.transform.createTransform.breadcrumbTitle": "创建转换", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "删除目标索引 {destinationIndex} 时发生错误", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage": "删除索引模式 {destinationIndex} 时发生错误", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage": "删除索引模式 {destinationIndex} 的请求已确认。", - "xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage": "删除目标索引 {destinationIndex} 的请求已确认。", - "xpack.transform.deleteTransform.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage": "检查索引模式 {indexPattern} 是否存在时发生错误:{error}", - "xpack.transform.description": "描述", - "xpack.transform.groupby.popoverForm.aggLabel": "聚合", - "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "其他分组依据配置已使用该名称。", - "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "名称无效。不允许使用字符“[”、“]”和“>”,且名称不得以空格字符开头或结束。", - "xpack.transform.groupBy.popoverForm.fieldLabel": "字段", - "xpack.transform.groupBy.popoverForm.intervalError": "时间间隔无效。", - "xpack.transform.groupBy.popoverForm.intervalLabel": "时间间隔", - "xpack.transform.groupBy.popoverForm.intervalPercents": "输入百分位数的逗号分隔列表", - "xpack.transform.groupBy.popoverForm.nameLabel": "分组依据名称", - "xpack.transform.groupBy.popoverForm.submitButtonLabel": "应用", - "xpack.transform.groupBy.popoverForm.unsupportedGroupByHelpText": "在此表单中仅可以编辑 group_by 名称。请使用高级编辑器编辑 group_by 配置的其他部分。", - "xpack.transform.groupByLabelForm.deleteItemAriaLabel": "删除项", - "xpack.transform.groupByLabelForm.editIntervalAriaLabel": "编辑时间间隔", - "xpack.transform.home.breadcrumbTitle": "数据帧作业", - "xpack.transform.indexPreview.copyClipboardTooltip": "将索引预览的开发控制台语句复制到剪贴板。", - "xpack.transform.licenseCheckErrorMessage": "许可证检查失败", - "xpack.transform.list.emptyPromptButtonText": "创建您的首个转换", - "xpack.transform.list.emptyPromptTitle": "找不到转换", - "xpack.transform.list.errorPromptTitle": "获取数据帧转换列表时发生错误。", - "xpack.transform.mode": "模式", - "xpack.transform.modeFilter": "模式", - "xpack.transform.models.transformService.allOtherRequestsCancelledDescription": "所有其他请求已取消。", - "xpack.transform.models.transformService.requestToActionTimedOutErrorMessage": "对 {action}“{id}”的请求超时。{extra}", - "xpack.transform.multiTransformActionsMenu.managementActionsAriaLabel": "管理操作", - "xpack.transform.multiTransformActionsMenu.transformsCount": "已选择 {count} 个{count, plural, one {转换} other {转换}}", - "xpack.transform.newTransform.chooseSourceTitle": "选择源", - "xpack.transform.newTransform.newTransformTitle": "新转换", - "xpack.transform.newTransform.searchSelection.notFoundLabel": "未找到匹配的索引或已保存搜索。", - "xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "索引模式", - "xpack.transform.newTransform.searchSelection.savedObjectType.search": "已保存搜索", - "xpack.transform.pivotPreview.copyClipboardTooltip": "将透视预览的开发控制台语句复制到剪贴板。", - "xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "请至少选择一个分组依据字段和聚合。", - "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "预览请求未返回任何数据。请确保可选查询返回数据且存在分组依据和聚合字段使用的字段的值。", - "xpack.transform.pivotPreview.PivotPreviewTitle": "转换数据透视表预览", - "xpack.transform.progress": "进度", - "xpack.transform.statsBar.batchTransformsLabel": "批量", - "xpack.transform.statsBar.continuousTransformsLabel": "连续", - "xpack.transform.statsBar.failedTransformsLabel": "失败", - "xpack.transform.statsBar.startedTransformsLabel": "已启动", - "xpack.transform.statsBar.totalTransformsLabel": "转换总数", - "xpack.transform.status": "状态", - "xpack.transform.statusFilter": "状态", - "xpack.transform.stepCreateForm.continuousModeLabel": "连续模式", - "xpack.transform.stepCreateForm.copyTransformConfigToClipboardButton": "复制到剪贴板", - "xpack.transform.stepCreateForm.copyTransformConfigToClipboardDescription": "将用于创建作业的 Kibana 开发控制台命令复制到剪贴板。", - "xpack.transform.stepCreateForm.createAndStartTransformButton": "创建并启动", - "xpack.transform.stepCreateForm.createAndStartTransformDescription": "创建并启动转换。转换将增加集群的搜索和索引负荷。如果负荷超载,请停止转换。转换启动后,系统将为您提供继续浏览转换的选项。", - "xpack.transform.stepCreateForm.createIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误 {indexPatternName}:", - "xpack.transform.stepCreateForm.createIndexPatternLabel": "创建索引模式", - "xpack.transform.stepCreateForm.createIndexPatternSuccessMessage": "Kibana 索引模式 {indexPatternName} 成功创建。", - "xpack.transform.stepCreateForm.createTransformButton": "创建", - "xpack.transform.stepCreateForm.createTransformDescription": "在不启动转换的情况下创建转换。您之后能够通过返回到转换列表,来启动转换。", - "xpack.transform.stepCreateForm.createTransformErrorMessage": "创建转换 {transformId} 时出错:", - "xpack.transform.stepCreateForm.createTransformSuccessMessage": "创建转换 {transformId} 的请求已确认。", - "xpack.transform.stepCreateForm.creatingIndexPatternMessage": "正在创建 Kibana 索引模式......", - "xpack.transform.stepCreateForm.discoverCardDescription": "使用 Discover 浏览数据帧透视表。", - "xpack.transform.stepCreateForm.discoverCardTitle": "Discover", - "xpack.transform.stepCreateForm.duplicateIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误 {indexPatternName}:该索引模式已存在。", - "xpack.transform.stepCreateForm.progressErrorMessage": "获取进度百分比时出错:", - "xpack.transform.stepCreateForm.progressTitle": "进度", - "xpack.transform.stepCreateForm.startTransformButton": "开始", - "xpack.transform.stepCreateForm.startTransformDescription": "启动转换。转换将增加集群的搜索和索引负荷。如果负荷超载,请停止转换。转换启动后,系统将为您提供继续浏览转换的选项。", - "xpack.transform.stepCreateForm.startTransformErrorMessage": "启动转换 {transformId} 时发生错误:", - "xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage": "调用启动转换请求时发生错误。", - "xpack.transform.stepCreateForm.startTransformSuccessMessage": "启动转换 {transformId} 的请求已确认。", - "xpack.transform.stepCreateForm.transformListCardDescription": "返回数据帧作业管理页面。", - "xpack.transform.stepCreateForm.transformListCardTitle": "数据帧作业", - "xpack.transform.stepDefineForm.addSubAggregationPlaceholder": "添加子聚合......", - "xpack.transform.stepDefineForm.advancedEditorApplyButtonText": "应用更改", - "xpack.transform.stepDefineForm.advancedEditorAriaLabel": "高级数据透视表编辑器", - "xpack.transform.stepDefineForm.advancedEditorHelpText": "高级编辑器允许您编辑数据帧转换的数据透视表配置。", - "xpack.transform.stepDefineForm.advancedEditorHelpTextLink": "详细了解可用选项。", - "xpack.transform.stepDefineForm.advancedEditorLabel": "数据透视表配置对象", - "xpack.transform.stepDefineForm.advancedEditorSourceConfigSwitchLabel": "编辑 JSON 查询", - "xpack.transform.stepDefineForm.advancedEditorSwitchLabel": "编辑 JSON 配置", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalBodyText": "高级编辑器中的更改尚未应用。禁用高级编辑器将会使您的编辑丢失。", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalCancelButtonText": "取消", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalConfirmButtonText": "禁用高级编辑器", - "xpack.transform.stepDefineForm.advancedEditorSwitchModalTitle": "未应用的更改", - "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "应用更改", - "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "高级查询编辑器", - "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高级编辑器允许您编辑转换配置的源查询子句。", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "切换回到查询栏,您将会丢失编辑。", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "切换至查询栏", - "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "编辑将会丢失", - "xpack.transform.stepDefineForm.aggExistsErrorMessage": "名称为“{aggName}”的聚合配置已存在。", - "xpack.transform.stepDefineForm.aggregationsLabel": "聚合", - "xpack.transform.stepDefineForm.aggregationsPlaceholder": "添加聚合……", - "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "名称为“{aggName}”的分组依据配置已存在。", - "xpack.transform.stepDefineForm.groupByLabel": "分组依据", - "xpack.transform.stepDefineForm.groupByPlaceholder": "添加分组依据字段……", - "xpack.transform.stepDefineForm.indexPatternLabel": "索引模式", - "xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar": "查询无效:{errorMessage}", - "xpack.transform.stepDefineForm.maxSubAggsLevelsLimitMessage": "您已达到可在表单中添加的最大子聚合级别数。如果想再添加一个级别,请编辑 JSON 配置。", - "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggListName}”有嵌套冲突。", - "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggNameCheck}”有嵌套冲突。", - "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{groupByListName}”有嵌套冲突。", - "xpack.transform.stepDefineForm.queryPlaceholderKql": "例如,{example}", - "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例如,{example}", - "xpack.transform.stepDefineForm.savedSearchLabel": "已保存搜索", - "xpack.transform.stepDefineSummary.aggregationsLabel": "聚合", - "xpack.transform.stepDefineSummary.groupByLabel": "分组依据", - "xpack.transform.stepDefineSummary.indexPatternLabel": "索引模式", - "xpack.transform.stepDefineSummary.queryCodeBlockLabel": "查询", - "xpack.transform.stepDefineSummary.queryLabel": "查询", - "xpack.transform.stepDefineSummary.savedSearchLabel": "已保存搜索", - "xpack.transform.stepDetailsForm.advancedSettingsAccordionButtonContent": "高级设置", - "xpack.transform.stepDetailsForm.continuousModeAriaLabel": "选择延迟。", - "xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText": "选择可用于标识新文档的日期字段。", - "xpack.transform.stepDetailsForm.continuousModeDateFieldLabel": "日期字段", - "xpack.transform.stepDetailsForm.continuousModeDelayError": "延迟格式无效", - "xpack.transform.stepDetailsForm.continuousModeDelayHelpText": "当前时间和最新输入数据时间之间的时间延迟。", - "xpack.transform.stepDetailsForm.continuousModeDelayLabel": "延迟", - "xpack.transform.stepDetailsForm.continuousModeError": "连续模式不可用于没有日期字段的索引。", - "xpack.transform.stepDetailsForm.destinationIndexHelpText": "已存在具有此名称的索引。请注意,运行此转换将会修改此目标索引。", - "xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel": "选择唯一目标索引名称。", - "xpack.transform.stepDetailsForm.destinationIndexInvalidError": "目标索引名称无效。", - "xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink": "详细了解索引名称限制。", - "xpack.transform.stepDetailsForm.destinationIndexLabel": "目标 IP", - "xpack.transform.stepDetailsForm.editFlyoutFormFrequencyPlaceholderText": "默认值:{defaultValue}", - "xpack.transform.stepDetailsForm.editFlyoutFormMaxPageSearchSizePlaceholderText": "默认值:{defaultValue}", - "xpack.transform.stepDetailsForm.errorGettingIndexNames": "获取现有索引名称时发生错误:", - "xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", - "xpack.transform.stepDetailsForm.errorGettingTransformList": "获取现有转换 ID 时发生错误:", - "xpack.transform.stepDetailsForm.errorGettingTransformPreview": "提取转换预览时发生错误", - "xpack.transform.stepDetailsForm.frequencyAriaLabel": "选择频率。", - "xpack.transform.stepDetailsForm.frequencyError": "频率格式无效", - "xpack.transform.stepDetailsForm.frequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。", - "xpack.transform.stepDetailsForm.frequencyLabel": "频率", - "xpack.transform.stepDetailsForm.indexPatternTimeFieldHelpText": "选择用于全局时间筛选的主要时间字段。", - "xpack.transform.stepDetailsForm.indexPatternTimeFieldLabel": "时间字段", - "xpack.transform.stepDetailsForm.indexPatternTitleError": "具有此名称的索引模式已存在。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeAriaLabel": "选择最大页面搜索大小。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeError": "max_page_search_size 必须是介于 10 到 10000 之间的数字。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText": "定义用于每个检查点的组合聚合的初始页面大小。", - "xpack.transform.stepDetailsForm.maxPageSearchSizeLabel": "最大页面搜索大小", - "xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "我不想使用时间筛选", - "xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "选择可选的转换描述。", - "xpack.transform.stepDetailsForm.transformDescriptionLabel": "转换描述", - "xpack.transform.stepDetailsForm.transformDescriptionPlaceholderText": "描述(可选)", - "xpack.transform.stepDetailsForm.transformIdExistsError": "已存在具有此 ID 的转换。", - "xpack.transform.stepDetailsForm.transformIdInputAriaLabel": "选择唯一的作业 ID。", - "xpack.transform.stepDetailsForm.transformIdInvalidError": "只能包含小写字母数字字符(a-z 和 0-9)、连字符和下划线,并且必须以字母数字字符开头和结尾。", - "xpack.transform.stepDetailsForm.transformIdLabel": "作业 ID", - "xpack.transform.stepDetailsSummary.advancedSettingsAccordionButtonContent": "高级设置", - "xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "连续模式日期字段", - "xpack.transform.stepDetailsSummary.createIndexPatternMessage": "将为此作业创建 Kibana 索引模式。", - "xpack.transform.stepDetailsSummary.destinationIndexLabel": "目标 IP", - "xpack.transform.stepDetailsSummary.frequencyLabel": "频率", - "xpack.transform.stepDetailsSummary.indexPatternTimeFieldLabel": "Kibana 索引模式时间字段", - "xpack.transform.stepDetailsSummary.maxPageSearchSizeLabel": "最大页面搜索大小", - "xpack.transform.stepDetailsSummary.transformDescriptionLabel": "转换描述", - "xpack.transform.stepDetailsSummary.transformIdLabel": "作业 ID", - "xpack.transform.tableActionLabel": "操作", - "xpack.transform.toastText.closeModalButtonText": "关闭", - "xpack.transform.toastText.modalTitle": "错误详细信息", - "xpack.transform.toastText.openModalButtonText": "查看详情", - "xpack.transform.transformForm.sizeNotationPlaceholder": "示例:{example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage": "已成功删除 {count} 个目标索引{count, plural, one {模式} other {模式}}。", - "xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage": "已成功删除 {count} 个目标{count, plural, one {索引} other {索引}}。", - "xpack.transform.transformList.bulkDeleteModalTitle": "删除 {count} 个 {count, plural, one {转换} other {转换}}?", - "xpack.transform.transformList.bulkDeleteTransformSuccessMessage": "已成功删除 {count} 个{count, plural, one {转换} other {转换}}。", - "xpack.transform.transformList.bulkStartModalTitle": "启动 {count} 个 {count, plural, one {转换} other {转换}}?", - "xpack.transform.transformList.cloneActionNameText": "克隆", - "xpack.transform.transformList.completeBatchTransformBulkActionToolTip": "一个或多个转换为已完成批量转换,无法重新启动。", - "xpack.transform.transformList.completeBatchTransformToolTip": "{transformId} 为已完成批量转换,无法重新启动。", - "xpack.transform.transformList.createTransformButton": "创建转换", - "xpack.transform.transformList.deleteActionDisabledToolTipContent": "停止数据帧作业,以便将其删除。", - "xpack.transform.transformList.deleteActionNameText": "删除", - "xpack.transform.transformList.deleteBulkActionDisabledToolTipContent": "一个或多个选定数据帧转换必须停止,才能删除。", - "xpack.transform.transformList.deleteModalCancelButton": "取消", - "xpack.transform.transformList.deleteModalDeleteButton": "删除", - "xpack.transform.transformList.deleteModalTitle": "删除 {transformId}?", - "xpack.transform.transformList.deleteTransformErrorMessage": "删除转换 {transformId} 时发生错误", - "xpack.transform.transformList.deleteTransformGenericErrorMessage": "调用用于删除转换的 API 终端节点时发生错误。", - "xpack.transform.transformList.deleteTransformSuccessMessage": "删除转换 {transformId} 的请求已确认。", - "xpack.transform.transformList.editActionNameText": "编辑", - "xpack.transform.transformList.editFlyoutCalloutDocs": "查看文档", - "xpack.transform.transformList.editFlyoutCalloutText": "此表单允许您更新转换。可以更新的属性列表是创建转换时可以定义的列表子集。", - "xpack.transform.transformList.editFlyoutCancelButtonText": "取消", - "xpack.transform.transformList.editFlyoutFormAdvancedSettingsButtonContent": "高级设置", - "xpack.transform.transformList.editFlyoutFormDescriptionLabel": "描述", - "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "目标配置", - "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "目标索引", - "xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "管道", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "要启用节流,请设置每秒要输入的文档限值。", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "每秒文档数", - "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。", - "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "频率", - "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "频率值无效。", - "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "默认值:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "定义用于每个检查点的组合聚合的初始页面大小。", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大页面搜索大小", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "默认值:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "值必须是大于零的整数。", - "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "值必须是介于 10 到 10000 之间的整数。", - "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必填字段。", - "xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage": "值需要为字符串类型。", - "xpack.transform.transformList.editFlyoutTitle": "编辑 {transformId}", - "xpack.transform.transformList.editFlyoutUpdateButtonText": "更新", - "xpack.transform.transformList.editTransformGenericErrorMessage": "调用用于更新转换的 API 终端时发生错误。", - "xpack.transform.transformList.editTransformSuccessMessage": "转换 {transformId} 已更新。", - "xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage": "检查用户是否可以删除目标索引时发生错误", - "xpack.transform.transformList.refreshButtonLabel": "刷新", - "xpack.transform.transformList.rowCollapse": "隐藏 {transformId} 的详情", - "xpack.transform.transformList.rowExpand": "显示 {transformId} 的详情", - "xpack.transform.transformList.showDetailsColumn.screenReaderDescription": "此列包含可单击控件,用于显示每个转换的更多详情", - "xpack.transform.transformList.startActionNameText": "启动", - "xpack.transform.transformList.startedTransformBulkToolTip": "一个或多个选定数据帧转换已启动。", - "xpack.transform.transformList.startedTransformToolTip": "{transformId} 已启动。", - "xpack.transform.transformList.startModalBody": "转换将增加集群的搜索和索引负载。如果超负荷,请停止转换。", - "xpack.transform.transformList.startModalCancelButton": "取消", - "xpack.transform.transformList.startModalStartButton": "启动", - "xpack.transform.transformList.startModalTitle": "启动 {transformId}?", - "xpack.transform.transformList.startTransformErrorMessage": "启动转换 {transformId} 时发生错误", - "xpack.transform.transformList.startTransformSuccessMessage": "启动转换 {transformId} 的请求已确认。", - "xpack.transform.transformList.stopActionNameText": "停止", - "xpack.transform.transformList.stoppedTransformBulkToolTip": "一个或多个选定数据帧转换已停止。", - "xpack.transform.transformList.stoppedTransformToolTip": "{transformId} 已停止。", - "xpack.transform.transformList.stopTransformErrorMessage": "停止数据帧转换 {transformId} 时发生错误", - "xpack.transform.transformList.stopTransformResponseSchemaErrorMessage": "调用停止转换请求时发生错误。", - "xpack.transform.transformList.stopTransformSuccessMessage": "停止数据帧转换 {transformId} 的请求已确认。", - "xpack.transform.transformList.transformDescription": "使用转换将现有 Elasticsearch 索引切换到摘要式或以实体为中心的索引。", - "xpack.transform.transformList.transformDetails.messagesPane.errorMessage": "无法加载消息", - "xpack.transform.transformList.transformDetails.messagesPane.messageLabel": "消息", - "xpack.transform.transformList.transformDetails.messagesPane.nodeLabel": "节点", - "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "时间", - "xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel": "详情", - "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "消息", - "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "预览", - "xpack.transform.transformList.transformDetails.tabs.transformStatsLabel": "统计", - "xpack.transform.transformList.transformDocsLinkText": "转换文档", - "xpack.transform.transformList.transformTitle": "数据帧作业", - "xpack.transform.transformsDescription": "使用转换将现有 Elasticsearch 索引透视成摘要式或以实体为中心的索引。", - "xpack.transform.transformsTitle": "转换", - "xpack.transform.transformsWizard.cloneTransformTitle": "克隆转换", - "xpack.transform.transformsWizard.createTransformTitle": "创建转换", - "xpack.transform.transformsWizard.stepConfigurationTitle": "配置", - "xpack.transform.transformsWizard.stepCreateTitle": "创建", - "xpack.transform.transformsWizard.stepDetailsTitle": "作业详情", - "xpack.transform.transformsWizard.transformDocsLinkText": "转换文档", - "xpack.transform.wizard.nextStepButton": "下一个", - "xpack.transform.wizard.previousStepButton": "上一页", "xpack.triggersActionsUI.actionVariables.alertIdLabel": "告警的 ID。", "xpack.triggersActionsUI.actionVariables.alertInstanceIdLabel": "为告警排定操作的告警实例 ID。", "xpack.triggersActionsUI.actionVariables.alertNameLabel": "告警的名称。", @@ -20066,42 +19804,6 @@ "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel": "取消", "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel": "删除{numIdsToDelete, plural, one {{singleTitle}} other { # 个{multipleTitle}}} ", "xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText": "无法恢复{numIdsToDelete, plural, one {删除的{singleTitle}} other {删除的{multipleTitle}}}。", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "选择边界名称", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "可人工读取的边界名称(可选)", - "xpack.stackAlerts.geoThreshold.delayOffset": "已延迟的评估偏移", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "评估延迟周期内的告警,以针对数据延迟进行调整", - "xpack.stackAlerts.geoThreshold.entityByLabel": "方式", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "索引", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "“边界地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "“边界索引模式标题”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "“边界类型”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "“日期”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "“实体”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "“地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "“索引模式”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "“跟踪事件”必填。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空间字段", - "xpack.stackAlerts.geoThreshold.indexLabel": "索引", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "索引模式", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "选择索引模式", - "xpack.stackAlerts.geoThreshold.name.trackingThreshold": "跟踪阈值", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "创建索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "您将需要 ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " (包含地理空间字段)。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "没有任何地理空间数据集? ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "找不到任何具有地理空间字段的索引模式", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "选择边界:", - "xpack.stackAlerts.geoThreshold.selectEntity": "选择实体", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectIndex": "定义条件", - "xpack.stackAlerts.geoThreshold.selectLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectOffset": "选择偏移(可选)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "选择时间字段", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "时间字段", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "选择实体字段", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "当实体", "xpack.triggersActionsUI.home.alertsTabTitle": "告警", "xpack.triggersActionsUI.home.appTitle": "告警和操作", "xpack.triggersActionsUI.home.breadcrumbTitle": "告警和操作", @@ -20145,15 +19847,6 @@ "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderValueText": "“值”必填。", "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText": "“方法”必填", "xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText": "“密码”必填。", - "xpack.stackAlerts.threshold.ui.validation.error.greaterThenThreshold0Text": "阈值 1 应 > 阈值 0。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredAggFieldText": "聚合字段必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredIndexText": "“索引”必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTermSizedText": "“词大小”必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold0Text": "阈值 0 必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredThreshold1Text": "阈值 1 必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeFieldText": "时间字段必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTimeWindowSizeText": "“时间窗大小”必填。", - "xpack.stackAlerts.threshold.ui.validation.error.requiredTermFieldText": "词字段必填。", "xpack.triggersActionsUI.sections.addConnectorForm.flyoutTitle": "{actionTypeName} 连接器", "xpack.triggersActionsUI.sections.addConnectorForm.selectConnectorFlyoutTitle": "选择连接器", "xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText": "无法创建连接器。", @@ -20162,27 +19855,11 @@ "xpack.triggersActionsUI.sections.addModalConnectorForm.flyoutTitle": "{actionTypeName} 连接器", "xpack.triggersActionsUI.sections.addModalConnectorForm.saveButtonLabel": "保存", "xpack.triggersActionsUI.sections.addModalConnectorForm.updateSuccessNotificationText": "已创建“{connectorName}”", - "xpack.stackAlerts.threshold.ui.conditionPrompt": "定义条件", - "xpack.stackAlerts.threshold.ui.visualization.errorLoadingAlertVisualizationTitle": "无法加载告警可视化", "xpack.triggersActionsUI.sections.alertAdd.flyoutTitle": "创建告警", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.threshold.ui.visualization.loadingAlertVisualizationDescription": "正在加载告警可视化……", "xpack.triggersActionsUI.sections.alertAdd.operationName": "创建", - "xpack.stackAlerts.threshold.ui.previewAlertVisualizationDescription": "完成表达式以生成预览。", "xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText": "无法创建告警。", "xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText": "已保存“{alertName}”", - "xpack.stackAlerts.threshold.ui.selectIndex": "选择索引", - "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "关闭", - "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "使用 * 可扩大您的查询范围。", - "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "索引", - "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "索引", - "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "要查询的索引", - "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "时间字段", "xpack.triggersActionsUI.sections.alertAdd.indexControls.timeFieldOptionLabel": "选择字段", - "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.dataDoesNotExistTextMessage": "确认您的时间范围和筛选正确。", - "xpack.stackAlerts.threshold.ui.visualization.thresholdPreviewChart.noDataTitle": "没有数据匹配此查询", - "xpack.stackAlerts.threshold.ui.visualization.unableToLoadVisualizationMessage": "无法加载可视化", "xpack.triggersActionsUI.sections.alertDetails.alertInstances.disabledAlert": "此告警已禁用,无法显示。切换禁用 ↑ 以激活。", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration": "持续时间", "xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance": "实例", @@ -20335,6 +20012,289 @@ "xpack.triggersActionsUI.timeUnits.secondLabel": "{timeValue, plural, one {秒} other {秒}}", "xpack.triggersActionsUI.typeRegistry.get.missingActionTypeErrorMessage": "未注册对象类型“{id}”。", "xpack.triggersActionsUI.typeRegistry.register.duplicateObjectTypeErrorMessage": "已注册对象类型“{id}”。", + "xpack.transform.actionDeleteTransform.bulkDeleteDestinationIndexTitle": "删除目标索引", + "xpack.transform.actionDeleteTransform.bulkDeleteDestIndexPatternTitle": "删除目标索引模式", + "xpack.transform.actionDeleteTransform.deleteDestinationIndexTitle": "删除目标索引 {destinationIndex}", + "xpack.transform.actionDeleteTransform.deleteDestIndexPatternTitle": "删除索引模式 {destinationIndex}", + "xpack.transform.agg.popoverForm.aggLabel": "聚合", + "xpack.transform.agg.popoverForm.aggNameAlreadyUsedError": "其他聚合已使用该名称。", + "xpack.transform.agg.popoverForm.aggNameInvalidCharError": "名称无效。不允许使用字符“[”、“]”和“>”,且名称不得以空格字符开头或结束。", + "xpack.transform.agg.popoverForm.fieldLabel": "字段", + "xpack.transform.agg.popoverForm.filerAgg.range.greaterThanLabel": "大于", + "xpack.transform.agg.popoverForm.filerAgg.range.lessThanLabel": "小于", + "xpack.transform.agg.popoverForm.filerAgg.term.errorFetchSuggestions": "无法获取建议", + "xpack.transform.agg.popoverForm.filerAgg.term.valueLabel": "值", + "xpack.transform.agg.popoverForm.filerAggLabel": "筛选查询", + "xpack.transform.agg.popoverForm.nameLabel": "聚合名称", + "xpack.transform.agg.popoverForm.percentsLabel": "百分数", + "xpack.transform.agg.popoverForm.submitButtonLabel": "应用", + "xpack.transform.agg.popoverForm.unsupportedAggregationHelpText": "在此表单中仅可以编辑聚合名称。请使用高级编辑器编辑聚合的其他部分。", + "xpack.transform.aggLabelForm.deleteItemAriaLabel": "删除项", + "xpack.transform.aggLabelForm.editAggAriaLabel": "编辑聚合", + "xpack.transform.app.checkingPrivilegesDescription": "正在检查权限……", + "xpack.transform.app.checkingPrivilegesErrorMessage": "从服务器获取用户权限时出错。", + "xpack.transform.app.deniedPrivilegeDescription": "要使用“转换”的此部分,必须具有{privilegesCount, plural, one {以下集群权限} other {以下集群权限}}:{missingPrivileges}。", + "xpack.transform.app.deniedPrivilegeTitle": "您缺少集群权限", + "xpack.transform.appName": "数据帧作业", + "xpack.transform.appTitle": "转换", + "xpack.transform.capability.noPermission.createTransformTooltip": "您无权创建数据帧转换。", + "xpack.transform.capability.noPermission.deleteTransformTooltip": "您无权删除数据帧转换。", + "xpack.transform.capability.noPermission.startOrStopTransformTooltip": "您无权启动或停止转换。", + "xpack.transform.capability.pleaseContactAdministratorTooltip": "{message}请联系您的管理员。", + "xpack.transform.clone.errorPromptText": "检查源索引模式是否存在时发生错误", + "xpack.transform.clone.errorPromptTitle": "获取转换配置时发生错误。", + "xpack.transform.clone.fetchErrorPromptText": "无法提取 Kibana 索引模式 ID。", + "xpack.transform.clone.noIndexPatternErrorPromptText": "无法克隆转换。对于 {indexPattern},不存在索引模式。", + "xpack.transform.cloneTransform.breadcrumbTitle": "克隆转换", + "xpack.transform.createTransform.breadcrumbTitle": "创建转换", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexErrorMessage": "删除目标索引 {destinationIndex} 时发生错误", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternErrorMessage": "删除索引模式 {destinationIndex} 时发生错误", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexPatternSuccessMessage": "删除索引模式 {destinationIndex} 的请求已确认。", + "xpack.transform.deleteTransform.deleteAnalyticsWithIndexSuccessMessage": "删除目标索引 {destinationIndex} 的请求已确认。", + "xpack.transform.deleteTransform.errorWithCheckingIfIndexPatternExistsNotificationErrorMessage": "检查索引模式 {indexPattern} 是否存在时发生错误:{error}", + "xpack.transform.description": "描述", + "xpack.transform.groupby.popoverForm.aggLabel": "聚合", + "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "其他分组依据配置已使用该名称。", + "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "名称无效。不允许使用字符“[”、“]”和“>”,且名称不得以空格字符开头或结束。", + "xpack.transform.groupBy.popoverForm.fieldLabel": "字段", + "xpack.transform.groupBy.popoverForm.intervalError": "时间间隔无效。", + "xpack.transform.groupBy.popoverForm.intervalLabel": "时间间隔", + "xpack.transform.groupBy.popoverForm.intervalPercents": "输入百分位数的逗号分隔列表", + "xpack.transform.groupBy.popoverForm.nameLabel": "分组依据名称", + "xpack.transform.groupBy.popoverForm.submitButtonLabel": "应用", + "xpack.transform.groupBy.popoverForm.unsupportedGroupByHelpText": "在此表单中仅可以编辑 group_by 名称。请使用高级编辑器编辑 group_by 配置的其他部分。", + "xpack.transform.groupByLabelForm.deleteItemAriaLabel": "删除项", + "xpack.transform.groupByLabelForm.editIntervalAriaLabel": "编辑时间间隔", + "xpack.transform.home.breadcrumbTitle": "数据帧作业", + "xpack.transform.indexPreview.copyClipboardTooltip": "将索引预览的开发控制台语句复制到剪贴板。", + "xpack.transform.licenseCheckErrorMessage": "许可证检查失败", + "xpack.transform.list.emptyPromptButtonText": "创建您的首个转换", + "xpack.transform.list.emptyPromptTitle": "找不到转换", + "xpack.transform.list.errorPromptTitle": "获取数据帧转换列表时发生错误。", + "xpack.transform.mode": "模式", + "xpack.transform.modeFilter": "模式", + "xpack.transform.models.transformService.allOtherRequestsCancelledDescription": "所有其他请求已取消。", + "xpack.transform.models.transformService.requestToActionTimedOutErrorMessage": "对 {action}“{id}”的请求超时。{extra}", + "xpack.transform.multiTransformActionsMenu.managementActionsAriaLabel": "管理操作", + "xpack.transform.multiTransformActionsMenu.transformsCount": "已选择 {count} 个{count, plural, one {转换} other {转换}}", + "xpack.transform.newTransform.chooseSourceTitle": "选择源", + "xpack.transform.newTransform.newTransformTitle": "新转换", + "xpack.transform.newTransform.searchSelection.notFoundLabel": "未找到匹配的索引或已保存搜索。", + "xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "索引模式", + "xpack.transform.newTransform.searchSelection.savedObjectType.search": "已保存搜索", + "xpack.transform.pivotPreview.copyClipboardTooltip": "将透视预览的开发控制台语句复制到剪贴板。", + "xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "请至少选择一个分组依据字段和聚合。", + "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "预览请求未返回任何数据。请确保可选查询返回数据且存在分组依据和聚合字段使用的字段的值。", + "xpack.transform.pivotPreview.PivotPreviewTitle": "转换数据透视表预览", + "xpack.transform.progress": "进度", + "xpack.transform.statsBar.batchTransformsLabel": "批量", + "xpack.transform.statsBar.continuousTransformsLabel": "连续", + "xpack.transform.statsBar.failedTransformsLabel": "失败", + "xpack.transform.statsBar.startedTransformsLabel": "已启动", + "xpack.transform.statsBar.totalTransformsLabel": "转换总数", + "xpack.transform.status": "状态", + "xpack.transform.statusFilter": "状态", + "xpack.transform.stepCreateForm.continuousModeLabel": "连续模式", + "xpack.transform.stepCreateForm.copyTransformConfigToClipboardButton": "复制到剪贴板", + "xpack.transform.stepCreateForm.copyTransformConfigToClipboardDescription": "将用于创建作业的 Kibana 开发控制台命令复制到剪贴板。", + "xpack.transform.stepCreateForm.createAndStartTransformButton": "创建并启动", + "xpack.transform.stepCreateForm.createAndStartTransformDescription": "创建并启动转换。转换将增加集群的搜索和索引负荷。如果负荷超载,请停止转换。转换启动后,系统将为您提供继续浏览转换的选项。", + "xpack.transform.stepCreateForm.createIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误 {indexPatternName}:", + "xpack.transform.stepCreateForm.createIndexPatternLabel": "创建索引模式", + "xpack.transform.stepCreateForm.createIndexPatternSuccessMessage": "Kibana 索引模式 {indexPatternName} 成功创建。", + "xpack.transform.stepCreateForm.createTransformButton": "创建", + "xpack.transform.stepCreateForm.createTransformDescription": "在不启动转换的情况下创建转换。您之后能够通过返回到转换列表,来启动转换。", + "xpack.transform.stepCreateForm.createTransformErrorMessage": "创建转换 {transformId} 时出错:", + "xpack.transform.stepCreateForm.createTransformSuccessMessage": "创建转换 {transformId} 的请求已确认。", + "xpack.transform.stepCreateForm.creatingIndexPatternMessage": "正在创建 Kibana 索引模式......", + "xpack.transform.stepCreateForm.discoverCardDescription": "使用 Discover 浏览数据帧透视表。", + "xpack.transform.stepCreateForm.discoverCardTitle": "Discover", + "xpack.transform.stepCreateForm.duplicateIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误 {indexPatternName}:该索引模式已存在。", + "xpack.transform.stepCreateForm.progressErrorMessage": "获取进度百分比时出错:", + "xpack.transform.stepCreateForm.progressTitle": "进度", + "xpack.transform.stepCreateForm.startTransformButton": "开始", + "xpack.transform.stepCreateForm.startTransformDescription": "启动转换。转换将增加集群的搜索和索引负荷。如果负荷超载,请停止转换。转换启动后,系统将为您提供继续浏览转换的选项。", + "xpack.transform.stepCreateForm.startTransformErrorMessage": "启动转换 {transformId} 时发生错误:", + "xpack.transform.stepCreateForm.startTransformResponseSchemaErrorMessage": "调用启动转换请求时发生错误。", + "xpack.transform.stepCreateForm.startTransformSuccessMessage": "启动转换 {transformId} 的请求已确认。", + "xpack.transform.stepCreateForm.transformListCardDescription": "返回数据帧作业管理页面。", + "xpack.transform.stepCreateForm.transformListCardTitle": "数据帧作业", + "xpack.transform.stepDefineForm.addSubAggregationPlaceholder": "添加子聚合......", + "xpack.transform.stepDefineForm.advancedEditorApplyButtonText": "应用更改", + "xpack.transform.stepDefineForm.advancedEditorAriaLabel": "高级数据透视表编辑器", + "xpack.transform.stepDefineForm.advancedEditorHelpText": "高级编辑器允许您编辑数据帧转换的数据透视表配置。", + "xpack.transform.stepDefineForm.advancedEditorHelpTextLink": "详细了解可用选项。", + "xpack.transform.stepDefineForm.advancedEditorLabel": "数据透视表配置对象", + "xpack.transform.stepDefineForm.advancedEditorSourceConfigSwitchLabel": "编辑 JSON 查询", + "xpack.transform.stepDefineForm.advancedEditorSwitchLabel": "编辑 JSON 配置", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalBodyText": "高级编辑器中的更改尚未应用。禁用高级编辑器将会使您的编辑丢失。", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalCancelButtonText": "取消", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalConfirmButtonText": "禁用高级编辑器", + "xpack.transform.stepDefineForm.advancedEditorSwitchModalTitle": "未应用的更改", + "xpack.transform.stepDefineForm.advancedSourceEditorApplyButtonText": "应用更改", + "xpack.transform.stepDefineForm.advancedSourceEditorAriaLabel": "高级查询编辑器", + "xpack.transform.stepDefineForm.advancedSourceEditorHelpText": "高级编辑器允许您编辑转换配置的源查询子句。", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalBodyText": "切换回到查询栏,您将会丢失编辑。", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText": "切换至查询栏", + "xpack.transform.stepDefineForm.advancedSourceEditorSwitchModalTitle": "编辑将会丢失", + "xpack.transform.stepDefineForm.aggExistsErrorMessage": "名称为“{aggName}”的聚合配置已存在。", + "xpack.transform.stepDefineForm.aggregationsLabel": "聚合", + "xpack.transform.stepDefineForm.aggregationsPlaceholder": "添加聚合……", + "xpack.transform.stepDefineForm.groupByExistsErrorMessage": "名称为“{aggName}”的分组依据配置已存在。", + "xpack.transform.stepDefineForm.groupByLabel": "分组依据", + "xpack.transform.stepDefineForm.groupByPlaceholder": "添加分组依据字段……", + "xpack.transform.stepDefineForm.indexPatternLabel": "索引模式", + "xpack.transform.stepDefineForm.invalidKuerySyntaxErrorMessageQueryBar": "查询无效:{errorMessage}", + "xpack.transform.stepDefineForm.maxSubAggsLevelsLimitMessage": "您已达到可在表单中添加的最大子聚合级别数。如果想再添加一个级别,请编辑 JSON 配置。", + "xpack.transform.stepDefineForm.nestedAggListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggListName}”有嵌套冲突。", + "xpack.transform.stepDefineForm.nestedConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{aggNameCheck}”有嵌套冲突。", + "xpack.transform.stepDefineForm.nestedGroupByListConflictErrorMessage": "无法添加配置“{aggName}”,因为与“{groupByListName}”有嵌套冲突。", + "xpack.transform.stepDefineForm.queryPlaceholderKql": "例如,{example}", + "xpack.transform.stepDefineForm.queryPlaceholderLucene": "例如,{example}", + "xpack.transform.stepDefineForm.savedSearchLabel": "已保存搜索", + "xpack.transform.stepDefineSummary.aggregationsLabel": "聚合", + "xpack.transform.stepDefineSummary.groupByLabel": "分组依据", + "xpack.transform.stepDefineSummary.indexPatternLabel": "索引模式", + "xpack.transform.stepDefineSummary.queryCodeBlockLabel": "查询", + "xpack.transform.stepDefineSummary.queryLabel": "查询", + "xpack.transform.stepDefineSummary.savedSearchLabel": "已保存搜索", + "xpack.transform.stepDetailsForm.advancedSettingsAccordionButtonContent": "高级设置", + "xpack.transform.stepDetailsForm.continuousModeAriaLabel": "选择延迟。", + "xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText": "选择可用于标识新文档的日期字段。", + "xpack.transform.stepDetailsForm.continuousModeDateFieldLabel": "日期字段", + "xpack.transform.stepDetailsForm.continuousModeDelayError": "延迟格式无效", + "xpack.transform.stepDetailsForm.continuousModeDelayHelpText": "当前时间和最新输入数据时间之间的时间延迟。", + "xpack.transform.stepDetailsForm.continuousModeDelayLabel": "延迟", + "xpack.transform.stepDetailsForm.continuousModeError": "连续模式不可用于没有日期字段的索引。", + "xpack.transform.stepDetailsForm.destinationIndexHelpText": "已存在具有此名称的索引。请注意,运行此转换将会修改此目标索引。", + "xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel": "选择唯一目标索引名称。", + "xpack.transform.stepDetailsForm.destinationIndexInvalidError": "目标索引名称无效。", + "xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink": "详细了解索引名称限制。", + "xpack.transform.stepDetailsForm.destinationIndexLabel": "目标 IP", + "xpack.transform.stepDetailsForm.editFlyoutFormFrequencyPlaceholderText": "默认值:{defaultValue}", + "xpack.transform.stepDetailsForm.editFlyoutFormMaxPageSearchSizePlaceholderText": "默认值:{defaultValue}", + "xpack.transform.stepDetailsForm.errorGettingIndexNames": "获取现有索引名称时发生错误:", + "xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", + "xpack.transform.stepDetailsForm.errorGettingTransformList": "获取现有转换 ID 时发生错误:", + "xpack.transform.stepDetailsForm.errorGettingTransformPreview": "提取转换预览时发生错误", + "xpack.transform.stepDetailsForm.frequencyAriaLabel": "选择频率。", + "xpack.transform.stepDetailsForm.frequencyError": "频率格式无效", + "xpack.transform.stepDetailsForm.frequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。", + "xpack.transform.stepDetailsForm.frequencyLabel": "频率", + "xpack.transform.stepDetailsForm.indexPatternTimeFieldHelpText": "选择用于全局时间筛选的主要时间字段。", + "xpack.transform.stepDetailsForm.indexPatternTimeFieldLabel": "时间字段", + "xpack.transform.stepDetailsForm.indexPatternTitleError": "具有此名称的索引模式已存在。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeAriaLabel": "选择最大页面搜索大小。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeError": "max_page_search_size 必须是介于 10 到 10000 之间的数字。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeHelpText": "定义用于每个检查点的组合聚合的初始页面大小。", + "xpack.transform.stepDetailsForm.maxPageSearchSizeLabel": "最大页面搜索大小", + "xpack.transform.stepDetailsForm.noTimeFieldOptionLabel": "我不想使用时间筛选", + "xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel": "选择可选的转换描述。", + "xpack.transform.stepDetailsForm.transformDescriptionLabel": "转换描述", + "xpack.transform.stepDetailsForm.transformDescriptionPlaceholderText": "描述(可选)", + "xpack.transform.stepDetailsForm.transformIdExistsError": "已存在具有此 ID 的转换。", + "xpack.transform.stepDetailsForm.transformIdInputAriaLabel": "选择唯一的作业 ID。", + "xpack.transform.stepDetailsForm.transformIdInvalidError": "只能包含小写字母数字字符(a-z 和 0-9)、连字符和下划线,并且必须以字母数字字符开头和结尾。", + "xpack.transform.stepDetailsForm.transformIdLabel": "作业 ID", + "xpack.transform.stepDetailsSummary.advancedSettingsAccordionButtonContent": "高级设置", + "xpack.transform.stepDetailsSummary.continuousModeDateFieldLabel": "连续模式日期字段", + "xpack.transform.stepDetailsSummary.createIndexPatternMessage": "将为此作业创建 Kibana 索引模式。", + "xpack.transform.stepDetailsSummary.destinationIndexLabel": "目标 IP", + "xpack.transform.stepDetailsSummary.frequencyLabel": "频率", + "xpack.transform.stepDetailsSummary.indexPatternTimeFieldLabel": "Kibana 索引模式时间字段", + "xpack.transform.stepDetailsSummary.maxPageSearchSizeLabel": "最大页面搜索大小", + "xpack.transform.stepDetailsSummary.transformDescriptionLabel": "转换描述", + "xpack.transform.stepDetailsSummary.transformIdLabel": "作业 ID", + "xpack.transform.tableActionLabel": "操作", + "xpack.transform.toastText.closeModalButtonText": "关闭", + "xpack.transform.toastText.modalTitle": "错误详细信息", + "xpack.transform.toastText.openModalButtonText": "查看详情", + "xpack.transform.transformForm.sizeNotationPlaceholder": "示例:{example1}、{example2}、{example3}、{example4}", + "xpack.transform.transformList.bulkDeleteDestIndexPatternSuccessMessage": "已成功删除 {count} 个目标索引{count, plural, one {模式} other {模式}}。", + "xpack.transform.transformList.bulkDeleteDestIndexSuccessMessage": "已成功删除 {count} 个目标{count, plural, one {索引} other {索引}}。", + "xpack.transform.transformList.bulkDeleteModalTitle": "删除 {count} 个 {count, plural, one {转换} other {转换}}?", + "xpack.transform.transformList.bulkDeleteTransformSuccessMessage": "已成功删除 {count} 个{count, plural, one {转换} other {转换}}。", + "xpack.transform.transformList.bulkStartModalTitle": "启动 {count} 个 {count, plural, one {转换} other {转换}}?", + "xpack.transform.transformList.cloneActionNameText": "克隆", + "xpack.transform.transformList.completeBatchTransformBulkActionToolTip": "一个或多个转换为已完成批量转换,无法重新启动。", + "xpack.transform.transformList.completeBatchTransformToolTip": "{transformId} 为已完成批量转换,无法重新启动。", + "xpack.transform.transformList.createTransformButton": "创建转换", + "xpack.transform.transformList.deleteActionDisabledToolTipContent": "停止数据帧作业,以便将其删除。", + "xpack.transform.transformList.deleteActionNameText": "删除", + "xpack.transform.transformList.deleteBulkActionDisabledToolTipContent": "一个或多个选定数据帧转换必须停止,才能删除。", + "xpack.transform.transformList.deleteModalCancelButton": "取消", + "xpack.transform.transformList.deleteModalDeleteButton": "删除", + "xpack.transform.transformList.deleteModalTitle": "删除 {transformId}?", + "xpack.transform.transformList.deleteTransformErrorMessage": "删除转换 {transformId} 时发生错误", + "xpack.transform.transformList.deleteTransformGenericErrorMessage": "调用用于删除转换的 API 终端节点时发生错误。", + "xpack.transform.transformList.deleteTransformSuccessMessage": "删除转换 {transformId} 的请求已确认。", + "xpack.transform.transformList.editActionNameText": "编辑", + "xpack.transform.transformList.editFlyoutCalloutDocs": "查看文档", + "xpack.transform.transformList.editFlyoutCalloutText": "此表单允许您更新转换。可以更新的属性列表是创建转换时可以定义的列表子集。", + "xpack.transform.transformList.editFlyoutCancelButtonText": "取消", + "xpack.transform.transformList.editFlyoutFormAdvancedSettingsButtonContent": "高级设置", + "xpack.transform.transformList.editFlyoutFormDescriptionLabel": "描述", + "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "目标配置", + "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "目标索引", + "xpack.transform.transformList.editFlyoutFormDestinationPipelineLabel": "管道", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "要启用节流,请设置每秒要输入的文档限值。", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "每秒文档数", + "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。", + "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "频率", + "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "频率值无效。", + "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "默认值:{defaultValue}", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "定义用于每个检查点的组合聚合的初始页面大小。", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大页面搜索大小", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "默认值:{defaultValue}", + "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "值必须是大于零的整数。", + "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "值必须是介于 10 到 10000 之间的整数。", + "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必填字段。", + "xpack.transform.transformList.editFlyoutFormStringNotValidErrorMessage": "值需要为字符串类型。", + "xpack.transform.transformList.editFlyoutTitle": "编辑 {transformId}", + "xpack.transform.transformList.editFlyoutUpdateButtonText": "更新", + "xpack.transform.transformList.editTransformGenericErrorMessage": "调用用于更新转换的 API 终端时发生错误。", + "xpack.transform.transformList.editTransformSuccessMessage": "转换 {transformId} 已更新。", + "xpack.transform.transformList.errorWithCheckingIfUserCanDeleteIndexNotificationErrorMessage": "检查用户是否可以删除目标索引时发生错误", + "xpack.transform.transformList.refreshButtonLabel": "刷新", + "xpack.transform.transformList.rowCollapse": "隐藏 {transformId} 的详情", + "xpack.transform.transformList.rowExpand": "显示 {transformId} 的详情", + "xpack.transform.transformList.showDetailsColumn.screenReaderDescription": "此列包含可单击控件,用于显示每个转换的更多详情", + "xpack.transform.transformList.startActionNameText": "启动", + "xpack.transform.transformList.startedTransformBulkToolTip": "一个或多个选定数据帧转换已启动。", + "xpack.transform.transformList.startedTransformToolTip": "{transformId} 已启动。", + "xpack.transform.transformList.startModalBody": "转换将增加集群的搜索和索引负载。如果超负荷,请停止转换。", + "xpack.transform.transformList.startModalCancelButton": "取消", + "xpack.transform.transformList.startModalStartButton": "启动", + "xpack.transform.transformList.startModalTitle": "启动 {transformId}?", + "xpack.transform.transformList.startTransformErrorMessage": "启动转换 {transformId} 时发生错误", + "xpack.transform.transformList.startTransformSuccessMessage": "启动转换 {transformId} 的请求已确认。", + "xpack.transform.transformList.stopActionNameText": "停止", + "xpack.transform.transformList.stoppedTransformBulkToolTip": "一个或多个选定数据帧转换已停止。", + "xpack.transform.transformList.stoppedTransformToolTip": "{transformId} 已停止。", + "xpack.transform.transformList.stopTransformErrorMessage": "停止数据帧转换 {transformId} 时发生错误", + "xpack.transform.transformList.stopTransformResponseSchemaErrorMessage": "调用停止转换请求时发生错误。", + "xpack.transform.transformList.stopTransformSuccessMessage": "停止数据帧转换 {transformId} 的请求已确认。", + "xpack.transform.transformList.transformDescription": "使用转换将现有 Elasticsearch 索引切换到摘要式或以实体为中心的索引。", + "xpack.transform.transformList.transformDetails.messagesPane.errorMessage": "无法加载消息", + "xpack.transform.transformList.transformDetails.messagesPane.messageLabel": "消息", + "xpack.transform.transformList.transformDetails.messagesPane.nodeLabel": "节点", + "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "时间", + "xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel": "详情", + "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "消息", + "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "预览", + "xpack.transform.transformList.transformDetails.tabs.transformStatsLabel": "统计", + "xpack.transform.transformList.transformDocsLinkText": "转换文档", + "xpack.transform.transformList.transformTitle": "数据帧作业", + "xpack.transform.transformsDescription": "使用转换将现有 Elasticsearch 索引透视成摘要式或以实体为中心的索引。", + "xpack.transform.transformsTitle": "转换", + "xpack.transform.transformsWizard.cloneTransformTitle": "克隆转换", + "xpack.transform.transformsWizard.createTransformTitle": "创建转换", + "xpack.transform.transformsWizard.stepConfigurationTitle": "配置", + "xpack.transform.transformsWizard.stepCreateTitle": "创建", + "xpack.transform.transformsWizard.stepDetailsTitle": "作业详情", + "xpack.transform.transformsWizard.transformDocsLinkText": "转换文档", + "xpack.transform.wizard.nextStepButton": "下一个", + "xpack.transform.wizard.previousStepButton": "上一页", "xpack.uiActionsEnhanced.components.actionWizard.betaActionLabel": "公测版", "xpack.uiActionsEnhanced.components.actionWizard.betaActionTooltip": "此操作位于公测版中,可能会有所更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束。请通过报告任何错误或提供其他反馈来帮助我们。", "xpack.uiActionsEnhanced.components.actionWizard.changeButton": "更改", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index bf54ab3f910453..b8514a06dc253c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -15,6 +15,7 @@ import { ActionTypeModel } from '../../../types'; import { getServiceNowActionType } from './servicenow'; import { getJiraActionType } from './jira'; import { getResilientActionType } from './resilient'; +import { getTeamsActionType } from './teams'; export function registerBuiltInActionTypes({ actionTypeRegistry, @@ -30,4 +31,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getServiceNowActionType()); actionTypeRegistry.register(getJiraActionType()); actionTypeRegistry.register(getResilientActionType()); + actionTypeRegistry.register(getTeamsActionType()); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/index.ts new file mode 100644 index 00000000000000..da407f786292a0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getTeamsActionType } from './teams'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.svg b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.svg new file mode 100644 index 00000000000000..ab07be8f1ef0a8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.svg @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve"> <image id="image0" width="256" height="256" x="0" y="0" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABGdBTUEAALGPC/xhBQAAACBjSFJN +AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAA +CXBIWXMAAA7DAAAOwwHHb6hkAAAbM0lEQVR42u3deZhU9Z0u8Pf9nVNVve/sYHSiaFxiRJYnmXvj +cp2ZaK4BTUAN6pCJA7mONzdxiUq3Wko3Ro3GubnDjEsCE0ENHQV0opkb3DNBlugljlsMM0YEFBro +ppvu6q4653v/AJcg3V3dXad+p+p8P8/Do1hVp97fsc/bZz+AUkoppZRSSimllFJKKaWUUkoppZRS +SimlChdtB1DhkbxHyjLtbZPcNCp9HvgDAEbQaQSdmRg63ZqGrckF7LadVeWGFkBEJe/qqMuk0qeL +j9MIHA/gWAEmQmTgnwlSCLwL4E0BXqPBc25J7NnkldV7bI9JDZ0WQIQ03d5xDHr7LhHiXIAnD7qw +Z4sUQDZT8DgS8Qeav1f9lu2xquxoARS55FIp8ba3XSo+5wnk8/n4ToLrAC51J9Y9kPwGU7bngeqf +FkCRuuMOKe/oa/uWCK6CYJyVEMQOEndWxxv+6ZpruN/2PFGfpAVQZESENy5u+1vx0SJAg+08AECg +jQaNtyxsuI+k2M6jPqIFUESSLe1TMn5miUBm2M5yOATXu8a9PNlY85LtLOoALYAikBQx6ZbdCwEk +IeLYzjMg0iN5s7uwriVJ+rbjRJ0WQIFraeka0yOp5SJylu0sQ0FybSlLLm5srHjfdpYo0wIoYDd8 +f+/JkvaeEMh421mGg+B2xpxzFl1Xu9l2lqgytgOo4blh8d7T/Iz3XKEu/AAgkPF+xnvuhsV7T7Od +Jaq0AArQjc27zhUv80uIVNvOMmIi1eJlfnlj865zbUeJIt0EKDA3LN57mniZXwpQYjtLLhFI0XG/ +tGhh7XO2s0SJFkABueH7e0/2M95zRfGb/3DIDuM6p+k+gfzRAigQLS1dY3r81EuFvM2fDYLbS03J +FD06kB+6D6AAJEVMj6SWF/vCDxzYMdgjqeVJEf3ZzAOdyQUgs3hPY6Ed5x8JETkrs3hPo+0cUaCb +ACGXbGmfkpbMhtCf4ZdrpBejO11PGw6WrgGEmIgw42eWRG7hPzB4J+Nnlkiu7lmgDit6P1gFxI9f +MV8E37Kdw6KJz/66e/vzT9/xW9tBipW2a0jdcYeUd6Ta3g7LJb22EGirLmk4Uu8nEAzdBAipjr62 +b0V94QcAARo6+tqivBYUKF0DCKHkUilJb2v7D2t38gkbYkdsQsOfFcLtxWavFMdbs3GK+PJfAR4n +kOMATCJZCTlwl2UQnSLSCWArwTcAeYOGLzgzp73UOodePvNqAYTQDS275vs+7rGdI0yMwYJFjaPu +tZ3jcObP3xTb1SXnQPyLAf6FYHhnahLsAORXoFk+qoJP3Hvv1HTQ2bUAQqhpUdtv8nUDz0JBcF3z +DQ1fsJ3j42Z/d2tpeueO71BwlYjU53S85G4h7oyNHnd36w8n9QQ1Bi2AkGm6veMY6e37ve0cYcRE +fHIYbjk+e6U43upN80T8mwWYEOiYgW2kucmZNXVZEJsHuhMwbHr7LrEdIbRCMG9mzd14dnr1ht/5 +4t8f9MIPAAJM8MW/P716w+9mzd14dq6nrwUQMgce2qEOx+a8ERGe9/X1LfD9JyA4Pv8BcDx8/4nz +vr6+JZcnR+kmQIgk7+qoS3en23L2xJ5iQ0qsLNaQ78eQzb781YpMe9dyEcy0PQsOzAascWsqLm5d +ckLXSKelawAhkkmlT9eFfwAizKTSp+fzKy+c9/KR6fb9vwnLwn9gNmBmun3/by6c9/KRI52WFkCI +iA+9N94g8jmPLpz38pG9fX3rIXKS7XF/ckbISb19fetHWgJaACFy8Cm9agD5mkezL3+1ItWXfkwE +o22PuT8iGJ3qSz82+/JXK4Y7DS2AcDnWdoACEPg8EhFm2ruWh/I3/yfDnnRg/8TwNh21AEIieY+U +CTDRdo6wE2Bi8h4pC/I7zp+7oTlM2/yDzhPBzPPnbmgezme1AEIi0942SXcAZkGEmfa2SUFNftbc +jWeLYKHtYQ6VCBYO5zwBLYCQoM8q2xkKRVDzavZKcSD+D2yPb9jE/8HslUO7eYwWQEhI5uCVYmpQ +Qc0rb/WmeVZO8skVwfHe6k3zhvIRLYCwcGTYe3IjJ4B5Nfu7W0tF/JttD22kRPybZ393a2m279cC +UApAeueO7+Tj3P6gCTAhvXPHd7J9vxZASBhBp+0MhSLX82r+/E0xCq6yPa5coeCq+fM3xbJ5rxZA +SGgBZC/X82pXl5yT6+v5bRKR+l1dck4279UCCIlMTAsgW7meVwKxfplxzol/cTZv0wIICbemYStI +sZ0j9Ehxaxq25mpys1eKQ0ERPnWJf5HNIUEtgJBILmA3gXdt5wg7Au8mF7A7V9Pz1mycMtx7+IWZ +QKq9NRunDPY+LYBwedN2gAKQ03l04O69xSmbsWkBhIgAr9nOEHY5n0fkZ2yPKTBZjM21nVF9pLPz +vXXw5du2cwAAjYGhA9dNwHVLYJxw/KjQ4LlcTk9EivYKzGzGFo7/qwoAkMqk1paYEgHsXxQkvg8P +Pjwvjd7eLsRipYgnKmCMxcdJklKGeE4LAEBgFxaFwKBj002AELn3zqltNPx32zkOJ53uQff+NniZ +PospZPPChVW7czlFkkV7DUY2Y9MCCBuRJ2xH6D+aoLtnj7USoODx3A+qiC/CymJsWgAhk4b8s+0M +AxKgp2cvfD+vj7A7IBF/wPbwi40WQMgsuf2k1wlstJ1jICKCvt4R35F6SAiuC+SpQCziMzCzGJsW +QAgZmp/azjCYdLoHvpfJ4zdyaRBTPfiU3qKUzdi0AEJob1nZ/SDet51jMJlMnp7WTexwJ9YFtfqf +s9OKQ2jQsfV7GHDWBU99Ou3hawTOBuQoAcdBJKtLDNXI7HplC3aNaAoCSB9E+uB77RBvD0R6P3qZ +/PD4fnnFGFRVjUeiZOj32MhkehFPBH8fExJ3Jr/BQNqG5JsixXk2IME3BnvPJwpg9uxnxnb70pzx +/HkQOB9dnaLXqRQOAkyATMAxlUBsIvzMbviZrRBJAyLIpFPIpFNI9bRj9643UVU9EQ2jj0U8nv0N +d30JfkcggbbqeMM/BfYFIq8HPghrZGgFMPOrT8/o9r1VAMaJLu9FhDBuA+hUw+t7C+J/cgfevo53 +sb9rJyZMmoqy8uwujRffDz65QeM113B/cNPnC+IX5w87DV8Y7D0f7gOY+dWnZ2QozwIYZzu4CgYZ +g5v4DGgOv9rueX3Y+sf16N6f03Nthp8XXH/Lwob7gvwOZ+a0lwh22B5rrhHscGZOe2mw9xngwGp/ +Bv4qiJTYDq6CRjjxY0AefneOiIdtWzehry9nV9wOMyY917iXM+B7JLTOoSfEWruDDYL8qnUOB91G +MwDQ7Usz9Dd/ZJAxGLf/08Q9rw9tO+1emUzy5mRjzaC/wXLyXWDxnWBEszybt5lZFzz1adKfZzuv +yi/j1oNM9Pv6vo530ZvK78k+HyC51l1Y15Kv7xtVwSdIhmO7JwdI7h5VwaxOKTdpD18TgcVLvJQd +BJ26Ad+xb992G6m2l7Lk4iQZ/B7Gg+69d2paiDvzPtiACHHnvfdOTWfzXnPgOL+KIuPUDPj6/q48 +n4tEdjDmnNPYWJH3k6Bio8fdTWBbvr831whsi40ed3e27zeAHGU7tLKDjA/4et7O9ANAIGWMM3PR +dbWbbcyL1h9O6iHNTTa+O5dIc1PrDyf1ZPt+I6Du/IuqQQugN8sJjTAGkDLEnEULa3N9s48hcWZN +XQYW8G3ZiNecWVOXDeUjRk/vjbJBbjyUj7PByA467pduaRqV+2v9h6h1Dj3QXG07x7DRXJ3Nob+P +04uBlDUEtxvXOc32b/6PW71i2pMkFtvOMVQkFq9eMe3JoX5OC0BZQXJtqSmZYmubfyCPrpjeRGKN +7RzZIrHm0RXTm4bzWS0AlV+kR2NudBvr/8rG3v7sIlLcmoqLQb5iO0sWYV9xayouHu4Zk1oAKm8I +ro/Rnd7cWL8on8f5h6N1yQldJfHYV0jstJ2lPyR2lsRjX2ldcsKwz9jSAlCBI9BmDBYsaqr/fL5O +782Fh5ed8nYiHp8RyjUB8pVEPD7j4WWnvD2SyWgBqOAQO2hwdXVJw5GLGkfdG/SFPUF4eNkpb8dq +yr8Qpn0CJNbEasq/MNKFH9AHg6ggkC8aylJnfMNPg7qTTz61LjmhS0TOO3/uhmYRLLSZhcTiAzsp +c1OmWgBq5EgBZDMFjyMRfyCQu/daHyIFQOOsuRt/DfF/AMHx+Q2A10Bz9aoV057kg7mbrBZAxJWU +1bVmMj3HZNKpSRDUDf5YMgoh20nzBx94nQ5/VYb4c7l+Yk9YrV4x7cnZK+X/eqs3zRPxbxZgQpDf +R2AbaW5yZk1dNtSTfLKa/pe/urbgtstU7vzikbM+XOBPPffxsk9V7z/OQU99X2+mLu311Hz66NN2 +i+d0unF/nwenoz2d+eMDPzg5sFt0FZLZ391amt654zsUXCUi2d1HLUskdwtxZ2z0uLuHcm7/kL9H +CyDaPl4Aanjmz98U29Ul5wjkEgrOEkj1cKZDsEOItQQfGFXBJ7K9pHckdBNAqRE6uKCuAbBm9kpx +0qs3nUrx/wvIzxx8RPckkpUfPquP6Dz40I6tJN+EyOtC82t31tTfBrGaPxDrBXDrzVNw3ORhFWbB +e/OtDlx3Y8EcFldZOLgAbzj4Z2hyuHMvW9YLwHWIWCyapyPE3GiOW4WH/gQqFWFaAEpFmBaAUhGm +BaBUhGkBKBVhWgBKRZj1w4DKrr88Z/GAZ4Ied8K5A36+smqs7SEMie/7yKQ99KbT2N/VjY593chk +MrZj5QyBLoDbQWwB+QsivubRFSe/29/7dQ1ARYoxBvFEDJUVZRg7tgGTJx+B8eMaEHOL4+FYAlQI +ZLKInC2+/3/ET7193tc33Hfh37w0/rDzw3ZgpWwigNraKhx99BGorCyzHSfnBHBE5LJUKv3GVy/a +8InVOS0ApQAYQxwxaSzq64v0tHRBpU+sPn/u+v/5J+O2nUupMBk7pr4o1wQAQESMCO/++JqAFoBS +h5g4YUzR7BM4lIgYj7Lig30CWgBKHcIYYtSoWtsxgiOo7O3JJIEQHAbcum0/nACvips0oQylpcMb +puf52PKfw77l+qDeeVdvrBNWNbVV2LmrvagOEf4p+Zvz526+xXoB/O9/fCPQ6d/efCqOP65mWJ/t +6srgyus2WpgryjYCqK4qw+49+2xHCYQADtA3UzcBlOpHeUVx7gz8gIh/jhaAUv1IxGK2IwSMn9YC +UKofbqw4jwR8RMZrASjVD2OKfPEQVBb5CJVSA9ECUCrCtACUijAtAKUiTAtAqQjTAlAqwrQAlIow +LQClIkwLQKkI0wJQKsK0AJSKMC0ApSJMC0CpCNMCUCrCtACUijAtAKUiTAtAqQjTAlAqwrQAlIow +LQClIkwLQKkI0wJQKsK0AJSKMC0ApSJMC0CpCNMCUCrCtACUijAtAKUiTAtAqQjTAlAqwrQAlIow +LQClIkwLQKkI0wJQKsK0AJSKMC0ApSJMC0CpCNMCUCrCtACUijAtAKUiTAtAqQjTAlAqwrQAlIow +LQClIkwLQKkI0wJQKsK0AJSKMC0ApSJMC0CpCNMCUCrCtACUijAtAKUiTAtAqQjTAlAqwrQAlIow +LQClIkwLQKkI0wJQKsJc2wGC1tfnI5XyhvXZVO/wPqdUISDpF30BNN3ysu0ISoWToFM3AZSKKAH2 +aQEoFVEEOrQAlIooXQNQKsJIvKMFoFRECfCqFoBSEeWI+XctAKWiio6uASgVScS+kyZ/bosWgFKR +xF8lk/S1AJSKIAM+eeCfSqnIiZc4WgBKRRNffvgnU7YDgAGZth1H2SK2AygLaPjjD/7dELLDdiBl +h+/12I6g8o3cX4aaBz74qwH4n7YzKTs8r9t2BJV/K1asOGbfB38xAjxpO5Gyo6/3fdsRQk2k+DaR +aNwlH/+7iTn4OQm99U3kCNKpbbZDhJr4xVUABH6+avmUzR//b2b1z/7bFhGzzHY4lV+p7nfgeftt +xwg1v4jWAEimXce9/tD/bgCgzLAJgO4MjAjfS6Gn61XbMULP93zbEXKGkHtal5/6h0P/uwGA1tYz +3nNhzgOZsh1UBUvER2f7i/D9XttRQq9o1gCIvU4idsvhXvrwRKA1j5y53hWeDl0TKFq+l8K+Pc8j +k96b5SdoO7JVvl8cu8YIc3nr0im7Dvfan5wJuOaRM9eXGWcKYH6sOwaLiSDV/Ud07H76TxZ+0hnw +U8Y4g024qPWli2ARIB9c9eC0h/t7+RO3BW9tPeM9AJfNuuCpW9MevkbgbECOEnAcRGK2x6OyIfC9 +HnheN/p630c6te2wO/wGW8CNKfq7xg8onS70k2S5tSYe+7sB32E7Ytidflbjj1I97VfYzhGEREkV +EonKfl8vLavDp4768wGnUVk11vYwArNt+y60t3fajjEsJFIQnLnqoRnrBnpftCs+C8aJbR75VMIp +5pYO+Ho8Xm47olXpdMZ2hGEh6RvhxY88NG3dYO/VqwEH0dPjrbedIQixeBmM03//JxKVcJxob/H1 +9RXmJoAAVz7y0LRHsnmvFsAg1j2/6BWQRXXMjDRIJKr6fd0YB6VltbZjWuV7fqGuAdy++sHpf5/t +m7UAsuCYWFGdM1taVj/ADkCiomJM5HcAdqcKr/MJXr/6oRnXDuUzWgBZcGJlr9jOkAukQVn5KLhu +/LCvG+Ogqmoc3FiJ7ajW9XQXzjlxBDwa881VD03//lA/qwWQBePEXrSdYaRi8TKUV4zud+FPJCpR +VT1hSAs/WbwHkXoKZQ2A2GfA81atmPaT4Xw82ut5WepNeb8AcKvtHENBOjDGgRsrQcwtPWSHH2GM +A2NcxOJliMfLh7XDr5g3E3p6+mxHGBTJda6TmNv6wMnDvqdH8VZ4jl106c/eEZFJtnPk2+ixJ/T7 +WjxRgUSiwnbEnEv19mHLlndtx+gXSR/krScfMzWZTHJEeyqLt8JzzcSehtf317ZjhEnMLc59BZ2d +4b1TEsmNEPlfqx6cvm5VDqanBZAlY2SN50EL4CA3VjLgeQSFrHt/6nUAn7Gd4+NIvCvk9Y8un7aC +ZM4uU9SdgFl6Y3PbvwDUO2jgwM6/gU4hLmQE2o6+78QTjWNmkrC/85d8hzDfc8eMn7x6xfTluVz4 +AV0DyNpvf7sgPfnE1mfF975sO4tVBEpLa4v4SkE+kSR9AI8BeOz8r286Q+BfAcF/F0h8pFPPOgXx +DIkfOTOnPdY6h4FdlqgFMASO4y7NRKkADjnMRxKlpbVw3LwtB3lnDJd//O+PPjj1GQDPnHfp+npk +zEUQuYiQGQLktAEJeAL8hjCPI+avWfXTGb8HAKwIdrx6FGBozIWXrHwf8BtsB8kHx02gvuFoAEAs +Vop4oqKIf/MDIN9ubqz/s8FWs+fNe7lmXzpzlvjylyA+B8FkgVQP7buwF8LNJDaTskEc/Ouqn87Y +ne8h6xrA0PiucdZkfP+btoMEjcZBPF6ORKICbqy0uBf8D8ZM/iSbbexly05pB/Dzg38AABdd9sqY +3u6eYwWYJGC5IcogKBNKAoJOkHsg2O0Ys8eNeW8/vHT6VtvjBbQAhqy8atRdmUzfN0SKfwdqVfV4 +xIvwOP/hEPRdF0uH+/mH7j/pfQAF96CFov8hzrV7fvTF10Cz1naOwBEoKRnaWm0hE8qTyWvrw3v2 +T0C0AIaD7u22IwStrKyuqHf2HcoY5zbbGayM23aAQvTjf/jiUzTO/7OdIyjGcVFZOc52jLwh8fyi +hXUv2M5hgxbAMBGxRbYzBDIuErW1nyras/wOS1iU/y+zoYcBR+Cyy5/+N9/3v2A7R64Y46C27sjI +7PgDAJAvtjQ1fN52DFt0DWAEHCauBnJ7aqYtpWU1GDX6uGgt/AAc8kbbGWzSNYARuuzvnnnU97zz +bOcYKuO4cJ0E4okKlJbVwnUTtiPlH7mqpanhfNsxbIrQhl4wJh5x4hXpNM+EDPFMMGUVgZTrOFfa +zmGbbgKMUPLaUdsN5DrbOdSQ3Za8vvZt2yFs0wLIgVsaG+4h8G+2c6gskW/XlTdE8rj/obQAcoCk +uI6ZTzD8N5KLOII+4Pz1lVeyx3aWMNACyJHkwvrXhGyxnUMNjMTtLU21z9vOERZaADl08uS6FpDP +2M6h+kG8NHFUfaQP+x1KCyCH5syhV8aSiwi8ZzuLOgTREzOcu2ABC/OBfwHRAsixxsaK9wFcBAZ3 +Gyc1HM43kwsb3rCdImy0AALQfMOoZwHoqmZIGLK5panuIds5wkjPBAxQY3PbUojMs50jygg+uqip +/mu5vptusdA1gADF/rz+bwk8YTtHVBF42R1df4ku/P3TAghQ8gxm3NENs0FusJ0lcsg3Sk3p2ckF +DO9jfkJACyBgyQXsjpXEv0zwTdtZooLAW2UsPfPgDlk1AC2APEheXdXmliS+COJ3trMUPeI/Ssgz +GhvLd9iOUgi0APIkeU3lzgondjpI+4+bKlIktsRc54ympoZttrMUCj0KkGfJf5CKzN7da0TkTNtZ +igr5YrlJfGXhwspdtqMUEi0AC5JLpSSzre0nIrjIdpaiQD5SX1Z/iV7gM3RaABY1Nu+6GuD3IVL8 +j90JiCHuchobrjn4QE81RFoAljUtajtLiIchUm87S0EhOwH5Hy1NowJ+fGZx0wIIgaZF7UcBmZ8J +ZJrtLAWBeCnmxC9IXl/9B9tRCp0WQEisXCnO797afR183JjP59AXGoJ/7x5b/73kHL35Si5oAYRM +smXPZ9Pi/TMEn7OdJVSI3zvk5bc0NjxlO0ox0QIIoXvukdg7u3ZfC8h1EJTbzmMTgZTQ3Dqmqu62 +b3+bvbbzFBstgBBL3rZrfDrNFgouFUj0Ttoi/zXmxK7Qbf3gaAEUgGRL+5SMn75LgNNsZ8kHAi8A +uPHgfRVUgLQACkjTol2ng7hWBF+ynSUIBNcBuLH5hoa1trNEhRZAAUq27PlsxvevAXChQAr66U4E +M0KscYh/1B18+acFUMCSt+2emMnIJSK4FCLH2c4zFCS3gryvFCX365V79mgBFIlkc9v0DHEpfLlA +gAbbeQ6HwHtCPObQPHriMXVr58zRG6fapgVQZJIixrt19zQRfgm+/JUQ061da3DgVlyvG+BfYLj6 +luvrXtTbc4WLFkCRu/XW9toeL3O6LzgVkFMATBFgbCBfRnYC2GCAdWLMOrfEeTF5ZfUe2/NA9U8L +IIJaWvaP60Xqs76PIwB/EsiJAkwEMIFAuUBKKEwIJXHwn70UdAqxD8A+CjoBvA9wC8AtdMwfHMff +ctP36rbpb3illFJKKaWUUkoppZRSSimllFJKKTv+PycghAJRYdeEAAAAJXRFWHRkYXRlOmNyZWF0 +ZQAyMDIwLTExLTEyVDE5OjU3OjQ1KzAzOjAw88nh2gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMC0x +MS0xMlQxOTo1Nzo0NSswMzowMIKUWWYAAAAASUVORK5CYII=" /> +</svg> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx new file mode 100644 index 00000000000000..5343e703628f7f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { TeamsActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.teams'; +let actionTypeModel: ActionTypeModel; + +beforeAll(async () => { + const actionTypeRegistry = new TypeRegistry<ActionTypeModel>(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + }); +}); + +describe('teams connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + webhookUrl: 'https:\\test', + }, + id: 'test', + actionTypeId: '.teams', + name: 'team', + config: {}, + } as TeamsActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid - empty webhook url', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.teams', + name: 'team', + config: {}, + } as TeamsActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is required.'], + }, + }); + }); + + test('connector validation fails when connector config is not valid - invalid webhook url', () => { + const actionConnector = { + secrets: { + webhookUrl: 'h', + }, + id: 'test', + actionTypeId: '.teams', + name: 'team', + config: {}, + } as TeamsActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is invalid.'], + }, + }); + }); + + test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http://insecure', + }, + id: 'test', + actionTypeId: '.teams', + name: 'team', + config: {}, + } as TeamsActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL must start with https://.'], + }, + }); + }); +}); + +describe('teams action params validation', () => { + test('if action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx new file mode 100644 index 00000000000000..bcfc21d3bfd5d3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import teamsSvg from './teams.svg'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { TeamsActionParams, TeamsSecrets, TeamsActionConnector } from '../types'; +import { isValidUrl } from '../../../lib/value_validators'; + +export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsActionParams> { + return { + id: '.teams', + iconClass: teamsSvg, + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.selectMessageText', + { + defaultMessage: 'Send a message to a Microsoft Teams channel.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.actionTypeTitle', + { + defaultMessage: 'Send a message to a Microsoft Teams channel.', + } + ), + validateConnector: (action: TeamsActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + webhookUrl: new Array<string>(), + }; + validationResult.errors = errors; + if (!action.secrets.webhookUrl) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } + ) + ); + } else if (action.secrets.webhookUrl) { + if (!isValidUrl(action.secrets.webhookUrl)) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } + ) + ); + } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } + ) + ); + } + } + return validationResult; + }, + validateParams: (actionParams: TeamsActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array<string>(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./teams_connectors')), + actionParamsFields: lazy(() => import('./teams_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx new file mode 100644 index 00000000000000..eaa7159db6a3d5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { TeamsActionConnector } from '../types'; +import TeamsActionFields from './teams_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('TeamsActionFields renders', () => { + test('all connector fields are rendered', async () => { + const actionConnector = { + secrets: { + webhookUrl: 'https:\\test', + }, + id: 'test', + actionTypeId: '.teams', + name: 'teams', + config: {}, + } as TeamsActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + <TeamsActionFields + action={actionConnector} + errors={{ index: [], webhookUrl: [] }} + editActionConfig={() => {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="teamsWebhookUrlInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="teamsWebhookUrlInput"]').first().prop('value')).toBe( + 'https:\\test' + ); + }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + actionTypeId: '.teams', + config: {}, + secrets: {}, + } as TeamsActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + <TeamsActionFields + action={actionConnector} + errors={{ index: [], webhookUrl: [] }} + editActionConfig={() => {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.teams', + name: 'teams', + config: {}, + } as TeamsActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + <TeamsActionFields + action={actionConnector} + errors={{ index: [], webhookUrl: [] }} + editActionConfig={() => {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx new file mode 100644 index 00000000000000..41dfc1325e8ed6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiCallOut, EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { TeamsActionConnector } from '../types'; + +const TeamsActionFields: React.FunctionComponent<ActionConnectorFieldsProps< + TeamsActionConnector +>> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { + const { webhookUrl } = action.secrets; + + return ( + <Fragment> + <EuiFormRow + id="webhookUrl" + fullWidth + helpText={ + <EuiLink + href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/teams-action-type.html#configuring-teams`} + target="_blank" + > + <FormattedMessage + id="xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlHelpLabel" + defaultMessage="Create a Microsoft Teams Webhook URL" + /> + </EuiLink> + } + error={errors.webhookUrl} + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel', + { + defaultMessage: 'Webhook URL', + } + )} + > + <Fragment> + {getEncryptedFieldNotifyLabel(!action.id)} + <EuiFieldText + fullWidth + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + name="webhookUrl" + readOnly={readOnly} + value={webhookUrl || ''} + data-test-subj="teamsWebhookUrlInput" + onChange={(e) => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + </Fragment> + </EuiFormRow> + </Fragment> + ); +}; + +function getEncryptedFieldNotifyLabel(isCreate: boolean) { + if (isCreate) { + return ( + <Fragment> + <EuiSpacer size="s" /> + <EuiText size="s" data-test-subj="rememberValuesMessage"> + <FormattedMessage + id="xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.rememberValueLabel" + defaultMessage="Remember this value. You must reenter it each time you edit the connector." + /> + </EuiText> + <EuiSpacer size="s" /> + </Fragment> + ); + } + return ( + <Fragment> + <EuiSpacer size="s" /> + <EuiCallOut + size="s" + iconType="iInCircle" + data-test-subj="reenterValuesMessage" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.reenterValueLabel', + { defaultMessage: 'This URL is encrypted. Please reenter a value for this field.' } + )} + /> + <EuiSpacer size="m" /> + </Fragment> + ); +} + +// eslint-disable-next-line import/no-default-export +export { TeamsActionFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx new file mode 100644 index 00000000000000..02ad3e33a28e0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import TeamsParamsFields from './teams_params'; +import { DocLinksStart } from 'kibana/public'; +import { coreMock } from 'src/core/public/mocks'; + +describe('TeamsParamsFields renders', () => { + test('all params fields is rendered', () => { + const mocks = coreMock.createSetup(); + const actionParams = { + message: 'test message', + }; + + const wrapper = mountWithIntl( + <TeamsParamsFields + actionParams={actionParams} + errors={{ message: [] }} + editAction={() => {}} + index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + /> + ); + expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="messageTextArea"]').first().prop('value')).toStrictEqual( + 'test message' + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx new file mode 100644 index 00000000000000..11eb3ec4e318e7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { TeamsActionParams } from '../types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; + +const TeamsParamsFields: React.FunctionComponent<ActionParamsProps<TeamsActionParams>> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, +}) => { + const { message } = actionParams; + useEffect(() => { + if (!message && defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <TextAreaWithMessageVariables + index={index} + editAction={editAction} + messageVariables={messageVariables} + paramsProperty={'message'} + inputTargetValue={message} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.messageTextAreaFieldLabel', + { + defaultMessage: 'Message', + } + )} + errors={errors.message as string[]} + /> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { TeamsParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index e22cd268f9bc5b..8db7d43f76a84d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -60,6 +60,10 @@ export interface SlackActionParams { message: string; } +export interface TeamsActionParams { + message: string; +} + export interface WebhookActionParams { body?: string; } @@ -119,3 +123,9 @@ export interface WebhookSecrets { } export type WebhookActionConnector = UserConfiguredActionConnector<WebhookConfig, WebhookSecrets>; + +export interface TeamsSecrets { + webhookUrl: string; +} + +export type TeamsActionConnector = UserConfiguredActionConnector<unknown, TeamsSecrets>; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index fc9db4a8b6b229..79ab7943d72a7c 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -20,7 +20,6 @@ "home", "data", "ml", - "apm", "maps" ] } diff --git a/x-pack/plugins/uptime/public/components/overview/kuery_bar/typeahead/suggestions.tsx b/x-pack/plugins/uptime/public/components/overview/kuery_bar/typeahead/suggestions.tsx index dcd8df1ba18efa..4e2b08d97cf4b8 100644 --- a/x-pack/plugins/uptime/public/components/overview/kuery_bar/typeahead/suggestions.tsx +++ b/x-pack/plugins/uptime/public/components/overview/kuery_bar/typeahead/suggestions.tsx @@ -10,10 +10,26 @@ import { isEmpty } from 'lodash'; import { tint } from 'polished'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { Suggestion } from './suggestion'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { units, px, unit } from '../../../../../../apm/public/style/variables'; import { QuerySuggestion } from '../../../../../../../../src/plugins/data/public'; +export const unit = 16; + +export const units = { + unit, + eighth: unit / 8, + quarter: unit / 4, + half: unit / 2, + minus: unit * 0.75, + plus: unit * 1.5, + double: unit * 2, + triple: unit * 3, + quadruple: unit * 4, +}; + +export function px(value: number): string { + return `${value}px`; +} + const List = styled.ul` width: 100%; border: 1px solid ${theme.euiColorLightShade}; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 7d4cc41cfbe5a3..505ad3c7d866b3 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -43,6 +43,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/security_api_integration/oidc.config.ts'), require.resolve('../test/security_api_integration/oidc_implicit_flow.config.ts'), require.resolve('../test/security_api_integration/token.config.ts'), + require.resolve('../test/security_api_integration/anonymous.config.ts'), require.resolve('../test/observability_api_integration/basic/config.ts'), require.resolve('../test/observability_api_integration/trial/config.ts'), require.resolve('../test/encrypted_saved_objects_api_integration/config.ts'), diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index 969f291b0d8b38..52ae28d75cc17e 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -23,7 +23,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await ml.testResources.deleteIndexPatternByTitle('ft_module_apache'); + await ml.testResources.deleteIndexPatternByTitle('ft_module_auditbeat'); await ml.testResources.deleteIndexPatternByTitle('ft_module_apm'); + await ml.testResources.deleteIndexPatternByTitle('ft_module_heartbeat'); await ml.testResources.deleteIndexPatternByTitle('ft_module_logs'); await ml.testResources.deleteIndexPatternByTitle('ft_module_nginx'); await ml.testResources.deleteIndexPatternByTitle('ft_module_sample_ecommerce'); @@ -36,7 +38,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.unload('ml/ecommerce'); await esArchiver.unload('ml/categorization'); await esArchiver.unload('ml/module_apache'); + await esArchiver.unload('ml/module_auditbeat'); await esArchiver.unload('ml/module_apm'); + await esArchiver.unload('ml/module_heartbeat'); await esArchiver.unload('ml/module_logs'); await esArchiver.unload('ml/module_nginx'); await esArchiver.unload('ml/module_sample_ecommerce'); diff --git a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts index d50148ec583a04..d327a27bc98217 100644 --- a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts @@ -115,6 +115,26 @@ export default ({ getService }: FtrProviderContext) => { moduleIds: [], }, }, + { + testTitleSuffix: 'for heartbeat dataset', + sourceDataArchive: 'ml/module_heartbeat', + indexPattern: 'ft_module_heartbeat', + user: USER.ML_POWERUSER, + expected: { + responseCode: 200, + moduleIds: ['uptime_heartbeat'], + }, + }, + { + testTitleSuffix: 'for auditbeat dataset', + sourceDataArchive: 'ml/module_auditbeat', + indexPattern: 'ft_module_auditbeat', + user: USER.ML_POWERUSER, + expected: { + responseCode: 200, + moduleIds: ['auditbeat_process_hosts_ecs', 'siem_auditbeat'], + }, + }, ]; async function executeRecognizeModuleRequest(indexPattern: string, user: USER, rspCode: number) { diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index fcf4c8d0c328f1..c86cd8400a71a4 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -451,6 +451,75 @@ export default ({ getService }: FtrProviderContext) => { dashboards: [] as string[], }, }, + { + testTitleSuffix: + 'for uptime_heartbeat with prefix, startDatafeed true and estimateModelMemory true', + sourceDataArchive: 'ml/module_heartbeat', + indexPattern: { name: 'ft_module_heartbeat', timeField: '@timestamp' }, + module: 'uptime_heartbeat', + user: USER.ML_POWERUSER, + requestBody: { + prefix: 'pf13_', + indexPatternName: 'ft_module_heartbeat', + startDatafeed: true, + end: Date.now(), + }, + expected: { + responseCode: 200, + jobs: [ + { + jobId: 'pf13_high_latency_by_geo', + jobState: JOB_STATE.CLOSED, + datafeedState: DATAFEED_STATE.STOPPED, + modelMemoryLimit: '11mb', + }, + ], + searches: [] as string[], + visualizations: [] as string[], + dashboards: [] as string[], + }, + }, + { + testTitleSuffix: + 'for auditbeat_process_hosts_ecs with prefix, startDatafeed true and estimateModelMemory true', + sourceDataArchive: 'ml/module_auditbeat', + indexPattern: { name: 'ft_module_auditbeat', timeField: '@timestamp' }, + module: 'auditbeat_process_hosts_ecs', + user: USER.ML_POWERUSER, + requestBody: { + prefix: 'pf14_', + indexPatternName: 'ft_module_auditbeat', + startDatafeed: true, + end: Date.now(), + }, + expected: { + responseCode: 200, + jobs: [ + { + jobId: 'pf14_hosts_high_count_process_events_ecs', + jobState: JOB_STATE.CLOSED, + datafeedState: DATAFEED_STATE.STOPPED, + modelMemoryLimit: '11mb', + }, + { + jobId: 'pf14_hosts_rare_process_activity_ecs', + jobState: JOB_STATE.CLOSED, + datafeedState: DATAFEED_STATE.STOPPED, + modelMemoryLimit: '11mb', + }, + ], + searches: ['ml_auditbeat_hosts_process_events_ecs'] as string[], + visualizations: [ + 'ml_auditbeat_hosts_process_event_rate_by_process_ecs', + 'ml_auditbeat_hosts_process_event_rate_vis_ecs', + 'ml_auditbeat_hosts_process_occurrence_ecs', + ] as string[], + dashboards: [ + 'ml_auditbeat_hosts_process_event_rate_ecs', + 'ml_auditbeat_hosts_process_explorer_ecs', + ] as string[], + }, + }, ]; const testDataListNegative = [ diff --git a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts index c2dfd28d5c844f..0137a90ce98170 100644 --- a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts +++ b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts @@ -82,10 +82,11 @@ export default function ({ getService }: FtrProviderContext) { }; describe('feature controls', () => { - let isProd = false; + let isProdOrCi = false; before(() => { const kbnConfig = config.get('servers.kibana'); - isProd = kbnConfig.hostname === 'localhost' && kbnConfig.port === 5620 ? false : true; + isProdOrCi = + !!process.env.CI || !(kbnConfig.hostname === 'localhost' && kbnConfig.port === 5620); }); it(`APIs can't be accessed by user with no privileges`, async () => { const username = 'logstash_read'; @@ -135,7 +136,7 @@ export default function ({ getService }: FtrProviderContext) { expectGraphQLResponse(graphQLResult); const graphQLIResult = await executeGraphIQLRequest(username, password); - if (!isProd) { + if (!isProdOrCi) { expectGraphIQLResponse(graphQLIResult); } else { expectGraphIQL404(graphQLIResult); @@ -234,7 +235,7 @@ export default function ({ getService }: FtrProviderContext) { expectGraphQLResponse(graphQLResult); const graphQLIResult = await executeGraphIQLRequest(username, password, space1Id); - if (!isProd) { + if (!isProdOrCi) { expectGraphIQLResponse(graphQLIResult); } else { expectGraphIQL404(graphQLIResult); diff --git a/x-pack/test/api_integration/services/usage_api.ts b/x-pack/test/api_integration/services/usage_api.ts index c56de5127f743c..b4adc6c61b6641 100644 --- a/x-pack/test/api_integration/services/usage_api.ts +++ b/x-pack/test/api_integration/services/usage_api.ts @@ -40,7 +40,7 @@ export function UsageAPIProvider({ getService }: FtrProviderContext) { async getTelemetryStats(payload: { unencrypted?: boolean; timestamp: number | string; - }): Promise<TelemetryCollectionManagerPlugin['getStats']> { + }): Promise<ReturnType<TelemetryCollectionManagerPlugin['getStats']>> { const { body } = await supertest .post('/api/telemetry/v2/clusters/_stats') .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts index 5fb6f21c51c95a..6ab29ffa09e130 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -33,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: comment } = await supertest @@ -55,7 +55,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body } = await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index c67eda1d3a16b6..180fc62d3d39ab 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -8,7 +8,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { CommentType } from '../../../../../../plugins/case/common/api'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -34,13 +35,13 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: caseComments } = await supertest @@ -63,13 +64,13 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send({ comment: 'unique', type: 'user' }) + .send({ comment: 'unique', type: CommentType.user }) .expect(200); const { body: caseComments } = await supertest @@ -91,7 +92,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 9c3a85e99c29d4..e77405f3cd49b0 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -33,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: comment } = await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 3176841b009d40..ca24f0d2e32c59 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + defaultUser, + postCaseReq, + postCommentUserReq, + postCommentAlertReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -33,7 +40,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; @@ -44,10 +51,43 @@ export default ({ getService }: FtrProviderContext): void => { id: patchedCase.comments[0].id, version: patchedCase.comments[0].version, comment: newComment, + type: CommentType.user, }) .expect(200); expect(body.comments[0].comment).to.eql(newComment); + expect(body.comments[0].type).to.eql('user'); + expect(body.updated_by).to.eql(defaultUser); + }); + + it('should patch an alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + const { body } = await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + type: CommentType.alert, + alertId: 'new-id', + index: postCommentAlertReq.index, + }) + .expect(200); + + expect(body.comments[0].alertId).to.eql('new-id'); + expect(body.comments[0].index).to.eql(postCommentAlertReq.index); + expect(body.comments[0].type).to.eql('alert'); expect(body.updated_by).to.eql(defaultUser); }); @@ -64,6 +104,7 @@ export default ({ getService }: FtrProviderContext): void => { .send({ id: 'id', version: 'version', + type: CommentType.user, comment: 'comment', }) .expect(404); @@ -76,12 +117,39 @@ export default ({ getService }: FtrProviderContext): void => { .send({ id: 'id', version: 'version', + type: CommentType.user, comment: 'comment', }) .expect(404); }); - it('unhappy path - 400s when patch body is bad', async () => { + it('unhappy path - 400s when trying to change comment type', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + }); + + it('unhappy path - 400s when missing attributes for type user', async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -91,7 +159,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); await supertest @@ -100,11 +168,100 @@ export default ({ getService }: FtrProviderContext): void => { .send({ id: patchedCase.comments[0].id, version: patchedCase.comments[0].version, - comment: true, }) .expect(400); }); + it('unhappy path - 400s when adding excess attributes for type user', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + for (const attribute of ['alertId', 'index']) { + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + comment: 'a comment', + type: CommentType.user, + [attribute]: attribute, + }) + .expect(400); + } + }); + + it('unhappy path - 400s when missing attributes for type alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + ...requestAttributes, + }) + .expect(400); + } + }); + + it('unhappy path - 400s when adding excess attributes for type alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + for (const attribute of ['comment']) { + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + [attribute]: attribute, + }) + .expect(400); + } + }); + it('unhappy path - 409s when conflict', async () => { const { body: postedCase } = await supertest .post(CASES_URL) @@ -115,7 +272,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; @@ -125,6 +282,7 @@ export default ({ getService }: FtrProviderContext): void => { .send({ id: patchedCase.comments[0].id, version: 'version-mismatch', + type: CommentType.user, comment: newComment, }) .expect(409); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index 0c7ab52abf8c87..d26e31394b9f56 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + defaultUser, + postCaseReq, + postCommentUserReq, + postCommentAlertReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -33,14 +40,50 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); - expect(patchedCase.comments[0].comment).to.eql(postCommentReq.comment); + expect(patchedCase.comments[0].type).to.eql(postCommentUserReq.type); + expect(patchedCase.comments[0].comment).to.eql(postCommentUserReq.comment); expect(patchedCase.updated_by).to.eql(defaultUser); }); - it('unhappy path - 400s when post body is bad', async () => { + it('should post an alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + expect(patchedCase.comments[0].type).to.eql(postCommentAlertReq.type); + expect(patchedCase.comments[0].alertId).to.eql(postCommentAlertReq.alertId); + expect(patchedCase.comments[0].index).to.eql(postCommentAlertReq.index); + expect(patchedCase.updated_by).to.eql(defaultUser); + }); + + it('unhappy path - 400s when type is missing', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + bad: 'comment', + }) + .expect(400); + }); + + it('unhappy path - 400s when missing attributes for type user', async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -50,6 +93,74 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') + .send({ type: CommentType.user }) + .expect(400); + }); + + it('unhappy path - 400s when adding excess attributes for type user', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + for (const attribute of ['alertId', 'index']) { + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ type: CommentType.user, [attribute]: attribute, comment: 'a comment' }) + .expect(400); + } + }); + + it('unhappy path - 400s when missing attributes for type alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(requestAttributes) + .expect(400); + } + }); + + it('unhappy path - 400s when adding excess attributes for type alert', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + for (const attribute of ['comment']) { + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + type: CommentType.alert, + [attribute]: attribute, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + } + }); + + it('unhappy path - 400s when case is missing', async () => { + await supertest + .post(`${CASES_URL}/not-exists/comments`) + .set('kbn-xsrf', 'true') .send({ bad: 'comment', }) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts index 73d17b985216af..ac64818fe629e1 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; -import { postCaseReq, postCommentReq } from '../../../common/lib/mock'; +import { postCaseReq, postCommentUserReq } from '../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -49,7 +49,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index 17814868fecc09..b119c71664f593 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; -import { postCaseReq, postCommentReq, findCasesResp } from '../../../common/lib/mock'; +import { postCaseReq, postCommentUserReq, findCasesResp } from '../../../common/lib/mock'; import { deleteCases, deleteComments, deleteCasesUserActions } from '../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -98,13 +98,13 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body } = await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index 80cf2c8199807d..3cf0d6892377ef 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../alerting_api_integration/common/lib'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../plugins/case/common/constants'; -import { postCaseReq, defaultUser, postCommentReq } from '../../../common/lib/mock'; +import { postCaseReq, defaultUser, postCommentUserReq } from '../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -130,7 +130,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body } = await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 92ef544ee9b379..6949052df47030 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -8,7 +8,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { CommentType } from '../../../../../../plugins/case/common/api'; +import { defaultUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -251,7 +252,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const { body } = await supertest @@ -264,7 +265,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); - expect(body[1].new_value).to.eql(postCommentReq.comment); + expect(body[1].new_value).to.eql(JSON.stringify(postCommentUserReq)); }); it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { @@ -277,7 +278,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq) + .send(postCommentUserReq) .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; @@ -285,6 +286,7 @@ export default ({ getService }: FtrProviderContext): void => { id: patchedCase.comments[0].id, version: patchedCase.comments[0].version, comment: newComment, + type: CommentType.user, }); const { body } = await supertest @@ -296,8 +298,13 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(3); expect(body[2].action_field).to.eql(['comment']); expect(body[2].action).to.eql('update'); - expect(body[2].old_value).to.eql(postCommentReq.comment); - expect(body[2].new_value).to.eql(newComment); + expect(body[2].old_value).to.eql(JSON.stringify(postCommentUserReq)); + expect(body[2].new_value).to.eql( + JSON.stringify({ + comment: newComment, + type: CommentType.user, + }) + ); }); it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 7a351d09b5b9f4..9a45dd541bb562 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { CommentType } from '../../../../../plugins/case/common/api'; import { postCaseReq, postCaseResp, @@ -616,9 +618,9 @@ export default ({ getService }: FtrProviderContext): void => { createdActionId = createdAction.id; const params = { - subAction: 'update', + subAction: 'addComment', subActionParams: { - comment: { comment: 'a comment', type: 'user' }, + comment: { comment: 'a comment', type: CommentType.user }, }, }; @@ -632,12 +634,12 @@ export default ({ getService }: FtrProviderContext): void => { status: 'error', actionId: createdActionId, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.caseId]: expected value of type [string] but got [undefined]', retry: false, }); }); - it('should respond with a 400 Bad Request when adding a comment to a case without comment', async () => { + it('should respond with a 400 Bad Request when missing attributes of type user', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -650,7 +652,7 @@ export default ({ getService }: FtrProviderContext): void => { createdActionId = createdAction.id; const params = { - subAction: 'update', + subAction: 'addComment', subActionParams: { caseId: '123', }, @@ -666,12 +668,143 @@ export default ({ getService }: FtrProviderContext): void => { status: 'error', actionId: createdActionId, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: expected at least one defined value but got [undefined]', retry: false, }); }); - it('should respond with a 400 Bad Request when adding a comment to a case without comment type', async () => { + it('should respond with a 400 Bad Request when missing attributes of type alert', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const comment = { alertId: 'test-id', index: 'test-index', type: CommentType.alert }; + const params = { + subAction: 'addComment', + subActionParams: { + caseId: '123', + comment, + }, + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, comment); + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...params, + subActionParams: { ...params.subActionParams, comment: requestAttributes }, + }, + }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]\n - [subActionParams.comment.1.${attribute}]: expected value of type [string] but got [undefined]`, + retry: false, + }); + } + }); + + it('should respond with a 400 Bad Request when adding excess attributes for type user', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'addComment', + subActionParams: { + caseId: '123', + comment: { comment: 'a comment', type: CommentType.user }, + }, + }; + + for (const attribute of ['alertId', 'index']) { + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...params, + subActionParams: { + ...params.subActionParams, + comment: { ...params.subActionParams.comment, [attribute]: attribute }, + }, + }, + }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]`, + retry: false, + }); + } + }); + + it('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'addComment', + subActionParams: { + caseId: '123', + comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + }, + }; + + for (const attribute of ['comment']) { + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...params, + subActionParams: { + ...params.subActionParams, + comment: { ...params.subActionParams.comment, [attribute]: attribute }, + }, + }, + }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]\n - [subActionParams.comment.1.${attribute}]: definition for this key is missing`, + retry: false, + }); + } + }); + + it('should respond with a 400 Bad Request when adding a comment to a case without type', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -706,7 +839,60 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should add a comment', async () => { + it('should add a comment of type user', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { comment: 'a comment', type: CommentType.user }, + }, + }; + + await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []); + expect({ ...data, comments }).to.eql({ + ...postCaseResp(caseRes.body.id), + comments, + totalComment: 1, + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); + }); + + it('should add a comment of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -729,7 +915,7 @@ export default ({ getService }: FtrProviderContext): void => { subAction: 'addComment', subActionParams: { caseId: caseRes.body.id, - comment: { comment: 'a comment', type: 'user' }, + comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, }, }; diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index d2262c684dc6da..a1e7f9a7fa89e1 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -10,6 +10,9 @@ import { CasesFindResponse, CommentResponse, ConnectorTypes, + CommentRequestUserType, + CommentRequestAlertType, + CommentType, } from '../../../../plugins/case/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { @@ -24,9 +27,15 @@ export const postCaseReq: CasePostRequest = { }, }; -export const postCommentReq: { comment: string; type: string } = { +export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', - type: 'user', + type: CommentType.user, +}; + +export const postCommentAlertReq: CommentRequestAlertType = { + alertId: 'test-id', + index: 'test-index', + type: CommentType.alert, }; export const postCaseResp = ( diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts index c682c1f1f46402..b653d469055035 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { PrePackagedRulesAndTimelinesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/response'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -13,6 +14,7 @@ import { deleteAllAlerts, deleteAllTimelines, deleteSignalsIndex, + installPrePackagedRules, waitFor, } from '../../utils'; @@ -45,18 +47,27 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllTimelines(es); }); - it('should contain rules_installed, rules_updated, timelines_installed, and timelines_updated', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(Object.keys(body)).to.eql([ + it('should create the prepackaged rules and return a count greater than zero, rules_updated to be zero, and contain the correct keys', async () => { + let responseBody: unknown; + await waitFor(async () => { + const { body, status } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send(); + if (status === 200) { + responseBody = body; + } + return status === 200; + }, DETECTION_ENGINE_PREPACKAGED_URL); + + const prepackagedRules = responseBody as PrePackagedRulesAndTimelinesSchema; + expect(prepackagedRules.rules_installed).to.be.greaterThan(0); + expect(prepackagedRules.rules_updated).to.eql(0); + expect(Object.keys(prepackagedRules)).to.eql([ 'rules_installed', 'rules_updated', 'timelines_installed', @@ -64,52 +75,8 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should create the prepackaged rules and return a count greater than zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_installed).to.be.greaterThan(0); - }); - - it('should create the prepackaged timelines and return a count greater than zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.timelines_installed).to.be.greaterThan(0); - }); - - it('should create the prepackaged rules that the rules_updated is of size zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_updated).to.eql(0); - }); - - it('should create the prepackaged timelines and the timelines_updated is of size zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.timelines_updated).to.eql(0); - }); - - it('should be possible to call the API twice and the second time the number of rules installed should be zero', async () => { - await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); + it('should be possible to call the API twice and the second time the number of rules installed should be zero as well as timeline', async () => { + await installPrePackagedRules(supertest); // NOTE: I call the GET call until eventually it becomes consistent and that the number of rules to install are zero. // This is to reduce flakiness where it can for a short period of time try to install the same rule twice. @@ -119,39 +86,23 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); return body.rules_not_installed === 0; - }); - - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_installed).to.eql(0); - }); - - it('should be possible to call the API twice and the second time the number of timelines installed should be zero', async () => { - await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); + }, `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`); + let responseBody: unknown; await waitFor(async () => { - const { body } = await supertest - .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + const { body, status } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') - .expect(200); - return body.timelines_not_installed === 0; - }); - - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.timelines_installed).to.eql(0); + .send(); + if (status === 200) { + responseBody = body; + } + return status === 200; + }, DETECTION_ENGINE_PREPACKAGED_URL); + + const prepackagedRules = responseBody as PrePackagedRulesAndTimelinesSchema; + expect(prepackagedRules.rules_installed).to.eql(0); + expect(prepackagedRules.timelines_installed).to.eql(0); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts index 53a8f1f4ca5c0b..a8a5f2abd072b9 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts @@ -25,7 +25,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('create_rules', () => { describe('validation errors', () => { @@ -51,7 +50,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should create a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules_bulk.ts index 6c3b1c45e202ec..73be4154db1ebd 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules_bulk.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('create_rules_bulk', () => { describe('validation errors', () => { @@ -54,7 +53,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should create a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules.ts index 7104e16f438c6d..786e9538432108 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('delete_rules', () => { describe('deleting rules', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules_bulk.ts index 35b31d2ccfefa1..66aa43e8a38172 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/delete_rules_bulk.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('delete_rules_bulk', () => { describe('deleting rules bulk using DELETE', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { @@ -146,7 +145,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts index 2610796bdc384b..4f76a0544a152a 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts @@ -22,7 +22,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('export_rules', () => { describe('exporting rules', () => { @@ -32,7 +31,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should set the response content types to be expected', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/find_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/find_rules.ts index f496d035d8e606..2f06a84c7223bc 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/find_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/find_rules.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('find_rules', () => { beforeEach(async () => { @@ -32,7 +31,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should return an empty find body correctly if no rules are loaded', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts index 9c20d58c5f4e58..fe80402b607312 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts @@ -30,7 +30,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllRulesStatuses(es); }); @@ -45,7 +45,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return a single rule status when a single rule is loaded from a find status with defaults added', async () => { - const resBody = await createRule(supertest, getSimpleRule()); + const resBody = await createRule(supertest, getSimpleRule('rule-1', true)); await waitForRuleSuccess(supertest, resBody.id); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts index 1bbfce42d2baad..c72b2e50b39fcf 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts @@ -32,7 +32,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllTimelines(es); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index c6294cfe6ec28b..f5774e09bb5e9a 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('import_rules', () => { describe('importing rules without an index', () => { @@ -39,7 +38,7 @@ export default ({ getService }: FtrProviderContext): void => { .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) .send(); return body.status_code === 404; - }); + }, `${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`); // Try to fetch the rule which should still be a 404 (not found) const { body } = await supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`).send(); @@ -86,7 +85,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should set the response content types to be expected', async () => { @@ -129,7 +128,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') .expect(200); const { body } = await supertest @@ -138,7 +137,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1')); + expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1', false)); }); it('should fail validation when importing a rule with malformed "from" params on the rules', async () => { @@ -330,7 +329,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') .expect(200); const simpleRule = getSimpleRule('rule-1'); @@ -422,17 +421,13 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') .expect(200); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach( - 'file', - getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3'], true), - 'rules.ndjson' - ) + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') .expect(200); const { body: bodyOfRule1 } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/install_prepackaged_timelines.ts b/x-pack/test/detection_engine_api_integration/basic/tests/install_prepackaged_timelines.ts index 556217877968b6..f70720cc752b26 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/install_prepackaged_timelines.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/install_prepackaged_timelines.ts @@ -29,7 +29,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllTimelines(es); }); @@ -72,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); return body.timelines_not_installed === 0; - }); + }, `${TIMELINE_PREPACKAGED_URL}/_status`); const { body } = await supertest .put(TIMELINE_PREPACKAGED_URL) diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts b/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts index a84d9845085e0a..f8a25b0081ef96 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts @@ -18,19 +18,19 @@ import { deleteSignalsIndex, setSignalStatus, getSignalStatusEmptyResponse, - getSimpleRule, getQuerySignalIds, deleteAllAlerts, createRule, waitForSignalsToBePresent, - getAllSignals, + getSignalsByIds, + waitForRuleSuccess, + getRuleForSignalTesting, } from '../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); describe('open_close_signals', () => { describe('validation checks', () => { @@ -66,29 +66,31 @@ export default ({ getService }: FtrProviderContext) => { describe('tests with auditbeat data', () => { beforeEach(async () => { - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await createSignalsIndex(supertest); await esArchiver.load('auditbeat/hosts'); }); afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await esArchiver.unload('auditbeat/hosts'); }); it('should be able to execute and get 10 signals', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).equal(10); }); it('should be have set the signals in an open state initially', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const everySignalOpen = signalsOpen.hits.hits.every( ({ _source: { @@ -100,10 +102,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able to get a count of 10 closed signals when closing 10', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // set all of the signals to the state of closed. There is no reason to use a waitUntil here @@ -126,10 +129,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able close 10 signals immediately and they all should be closed', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // set all of the signals to the state of closed. There is no reason to use a waitUntil here diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules.ts index 36a9649d875cac..28ea2e1ff88034 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('patch_rules', () => { describe('patch rules', () => { @@ -33,7 +32,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should patch a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules_bulk.ts index 69330a2bf682a2..e32771d0d917c7 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/patch_rules_bulk.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('patch_rules_bulk', () => { describe('patch rules bulk', () => { @@ -33,7 +32,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should patch a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/read_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/read_rules.ts index cfccb7436ea207..1697554441c16a 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/read_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/read_rules.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('read_rules', () => { describe('reading rules', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should be able to read a single rule using rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/update_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/update_rules.ts index 2f5a043881eeb0..d8e9c650c81169 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/update_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/update_rules.ts @@ -25,7 +25,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('update_rules', () => { describe('update rules', () => { @@ -35,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should update a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/update_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/basic/tests/update_rules_bulk.ts index 22aa40b0721a43..c5b65039aa1164 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/update_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/update_rules_bulk.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('update_rules_bulk', () => { describe('update rules bulk', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should update a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts index d473863e7d028b..bbd85e353e0955 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('add_actions', () => { describe('adding actions', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should be able to create a new webhook action and attach it to a rule', async () => { @@ -60,7 +59,7 @@ export default ({ getService }: FtrProviderContext) => { .send(getWebHookAction()) .expect(200); - const rule = await createRule(supertest, getRuleWithWebHookAction(hookAction.id)); + const rule = await createRule(supertest, getRuleWithWebHookAction(hookAction.id, true)); await waitForRuleSuccess(supertest, rule.id); // expected result for status should be 'succeeded' @@ -82,7 +81,7 @@ export default ({ getService }: FtrProviderContext) => { // create a rule with the action attached and a meta field const ruleWithAction: CreateRulesSchema = { - ...getRuleWithWebHookAction(hookAction.id), + ...getRuleWithWebHookAction(hookAction.id, true), meta: {}, }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index c889e152759a8e..b653d469055035 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { PrePackagedRulesAndTimelinesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/response'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -13,6 +14,7 @@ import { deleteAllAlerts, deleteAllTimelines, deleteSignalsIndex, + installPrePackagedRules, waitFor, } from '../../utils'; @@ -45,18 +47,27 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllTimelines(es); }); - it('should contain two output keys of rules_installed and rules_updated', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(Object.keys(body)).to.eql([ + it('should create the prepackaged rules and return a count greater than zero, rules_updated to be zero, and contain the correct keys', async () => { + let responseBody: unknown; + await waitFor(async () => { + const { body, status } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send(); + if (status === 200) { + responseBody = body; + } + return status === 200; + }, DETECTION_ENGINE_PREPACKAGED_URL); + + const prepackagedRules = responseBody as PrePackagedRulesAndTimelinesSchema; + expect(prepackagedRules.rules_installed).to.be.greaterThan(0); + expect(prepackagedRules.rules_updated).to.eql(0); + expect(Object.keys(prepackagedRules)).to.eql([ 'rules_installed', 'rules_updated', 'timelines_installed', @@ -64,74 +75,34 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should create the prepackaged rules and return a count greater than zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_installed).to.be.greaterThan(0); - }); - - it('should create the prepackaged rules that the rules_updated is of size zero', async () => { - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_updated).to.eql(0); - }); - - it('should be possible to call the API twice and the second time the number of rules installed should be zero', async () => { - await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); + it('should be possible to call the API twice and the second time the number of rules installed should be zero as well as timeline', async () => { + await installPrePackagedRules(supertest); // NOTE: I call the GET call until eventually it becomes consistent and that the number of rules to install are zero. - // This is to reduce flakiness where it can for a short period of time try to install the same rule the same rule twice. + // This is to reduce flakiness where it can for a short period of time try to install the same rule twice. await waitFor(async () => { const { body } = await supertest .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) .set('kbn-xsrf', 'true') .expect(200); return body.rules_not_installed === 0; - }); - - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.rules_installed).to.eql(0); - }); - - it('should be possible to call the API twice and the second time the number of timelines installed should be zero', async () => { - await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); + }, `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`); + let responseBody: unknown; await waitFor(async () => { - const { body } = await supertest - .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + const { body, status } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') - .expect(200); - return body.timelines_not_installed === 0; - }); - - const { body } = await supertest - .put(DETECTION_ENGINE_PREPACKAGED_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.timelines_installed).to.eql(0); + .send(); + if (status === 200) { + responseBody = body; + } + return status === 200; + }, DETECTION_ENGINE_PREPACKAGED_URL); + + const prepackagedRules = responseBody as PrePackagedRulesAndTimelinesSchema; + expect(prepackagedRules.rules_installed).to.eql(0); + expect(prepackagedRules.timelines_installed).to.eql(0); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts index 651a7601ca95a8..7e4a6ad86cda5c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts @@ -32,7 +32,7 @@ import { createExceptionList, createExceptionListItem, waitForSignalsToBePresent, - getAllSignals, + getSignalsByIds, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -49,7 +49,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllExceptions(es); }); @@ -101,6 +101,7 @@ export default ({ getService }: FtrProviderContext) => { const ruleWithException: CreateRulesSchema = { ...getSimpleRule(), + enabled: true, exceptions_list: [ { id, @@ -117,6 +118,7 @@ export default ({ getService }: FtrProviderContext) => { const expected: Partial<RulesSchema> = { ...getSimpleRuleOutput(), + enabled: true, exceptions_list: [ { id, @@ -397,7 +399,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllExceptions(es); await esArchiver.unload('auditbeat/hosts'); }); @@ -441,9 +443,10 @@ export default ({ getService }: FtrProviderContext) => { }, ], }; - await createRule(supertest, ruleWithException); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const { id: createdId } = await createRule(supertest, ruleWithException); + await waitForRuleSuccess(supertest, createdId); + await waitForSignalsToBePresent(supertest, 10, [createdId]); + const signalsOpen = await getSignalsByIds(supertest, [createdId]); expect(signalsOpen.hits.hits.length).equal(10); }); @@ -488,7 +491,7 @@ export default ({ getService }: FtrProviderContext) => { }; const rule = await createRule(supertest, ruleWithException); await waitForRuleSuccess(supertest, rule.id); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [rule.id]); expect(signalsOpen.hits.hits.length).equal(0); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index a18faf8543042e..0da12ebba055a0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -25,12 +25,12 @@ import { getSimpleMlRule, getSimpleMlRuleOutput, waitForRuleSuccess, + getRuleForSignalTesting, } from '../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('create_rules', () => { describe('validation errors', () => { @@ -56,7 +56,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should create a single rule with a rule_id', async () => { @@ -90,7 +90,7 @@ export default ({ getService }: FtrProviderContext) => { this pops up again elsewhere. */ it('should create a single rule with a rule_id and validate it ran successfully', async () => { - const simpleRule = getSimpleRule(); + const simpleRule = getRuleForSignalTesting(['auditbeat-*']); const { body } = await supertest .post(DETECTION_ENGINE_RULES_URL) .set('kbn-xsrf', 'true') @@ -105,8 +105,6 @@ export default ({ getService }: FtrProviderContext) => { .send({ ids: [body.id] }) .expect(200); - const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(getSimpleRuleOutput()); expect(statusBody[body.id].current_status.status).to.eql('succeeded'); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 58790dbfb759c7..7ea47312a50302 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -15,6 +15,7 @@ import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, + getRuleForSignalTesting, getSimpleRule, getSimpleRuleOutput, getSimpleRuleOutputWithoutRuleId, @@ -27,7 +28,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('create_rules_bulk', () => { describe('validation errors', () => { @@ -58,7 +58,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should create a single rule with a rule_id', async () => { @@ -92,7 +92,7 @@ export default ({ getService }: FtrProviderContext): void => { this pops up again elsewhere. */ it('should create a single rule with a rule_id and validate it ran successfully', async () => { - const simpleRule = getSimpleRule(); + const simpleRule = getRuleForSignalTesting(['auditbeat-*']); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) .set('kbn-xsrf', 'true') @@ -107,8 +107,6 @@ export default ({ getService }: FtrProviderContext): void => { .send({ ids: [body[0].id] }) .expect(200); - const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(getSimpleRuleOutput()); expect(statusBody[body[0].id].current_status.status).to.eql('succeeded'); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 36cd8480998c56..21cfab3db6d6a3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -17,7 +17,7 @@ import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, - getAllSignals, + getSignalsByIds, removeServerGeneratedProperties, waitForRuleSuccess, waitForSignalsToBePresent, @@ -30,7 +30,6 @@ import { getThreatMatchingSchemaPartialMock } from '../../../../plugins/security export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); /** * Specific api integration tests for threat matching rule type @@ -59,7 +58,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should create a single rule with a rule_id', async () => { @@ -69,7 +68,10 @@ export default ({ getService }: FtrProviderContext) => { }); it('should create a single rule with a rule_id and validate it ran successfully', async () => { - const ruleResponse = await createRule(supertest, getCreateThreatMatchRulesSchemaMock()); + const ruleResponse = await createRule( + supertest, + getCreateThreatMatchRulesSchemaMock('rule-1', true) + ); await waitForRuleSuccess(supertest, ruleResponse.id); const { body: statusBody } = await supertest @@ -79,21 +81,21 @@ export default ({ getService }: FtrProviderContext) => { .expect(200); const bodyToCompare = removeServerGeneratedProperties(ruleResponse); - expect(bodyToCompare).to.eql(getThreatMatchingSchemaPartialMock()); + expect(bodyToCompare).to.eql(getThreatMatchingSchemaPartialMock(true)); expect(statusBody[ruleResponse.id].current_status.status).to.eql('succeeded'); }); }); describe('tests with auditbeat data', () => { beforeEach(async () => { - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await createSignalsIndex(supertest); await esArchiver.load('auditbeat/hosts'); }); afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await esArchiver.unload('auditbeat/hosts'); }); @@ -125,9 +127,10 @@ export default ({ getService }: FtrProviderContext) => { threat_filters: [], }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).equal(10); }); @@ -161,7 +164,7 @@ export default ({ getService }: FtrProviderContext) => { const ruleResponse = await createRule(supertest, rule); await waitForRuleSuccess(supertest, ruleResponse.id); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [ruleResponse.id]); expect(signalsOpen.hits.hits.length).equal(0); }); @@ -199,7 +202,7 @@ export default ({ getService }: FtrProviderContext) => { const ruleResponse = await createRule(supertest, rule); await waitForRuleSuccess(supertest, ruleResponse.id); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [ruleResponse.id]); expect(signalsOpen.hits.hits.length).equal(0); }); @@ -237,7 +240,7 @@ export default ({ getService }: FtrProviderContext) => { const ruleResponse = await createRule(supertest, rule); await waitForRuleSuccess(supertest, ruleResponse.id); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [ruleResponse.id]); expect(signalsOpen.hits.hits.length).equal(0); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts index 7104e16f438c6d..786e9538432108 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('delete_rules', () => { describe('deleting rules', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts index 35b31d2ccfefa1..66aa43e8a38172 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('delete_rules_bulk', () => { describe('deleting rules bulk using DELETE', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { @@ -146,7 +145,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should delete a single rule with a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/README.md b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/README.md new file mode 100644 index 00000000000000..d6beb912d70075 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/README.md @@ -0,0 +1,21 @@ +These are tests for rule exception lists where we test each data type +* date +* double +* float +* integer +* ip +* keyword +* long +* text + +Against the operator types of: +* "is" +* "is not" +* "is one of" +* "is not one of" +* "exists" +* "does not exist" +* "is in list" +* "is not in list" + +If you add a test here, ensure you add it to the ./index.ts" file as well \ No newline at end of file diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/date.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/date.ts new file mode 100644 index 00000000000000..09cc470defa081 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/date.ts @@ -0,0 +1,611 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type date', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/date'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/date'); + }); + + describe('"is" operator', () => { + it('should find all the dates from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + + it('should filter 1 single date if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + + it('should filter 2 dates if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-02T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-03T05:08:53.000Z', '2020-10-04T05:08:53.000Z']); + }); + + it('should filter 3 dates if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-02T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-03T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-04T05:08:53.000Z']); + }); + + it('should filter 4 dates if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-02T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-03T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'included', + type: 'match', + value: '2020-10-04T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'match', + value: '2021-10-01T05:08:53.000Z', // date is not in data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-01T05:08:53.000Z']); + }); + + it('will return 0 results if we exclude two dates', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'match', + value: '2020-10-01T05:08:53.000Z', + }, + ], + [ + { + field: 'date', + operator: 'excluded', + type: 'match', + value: '2020-10-02T05:08:53.000Z', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single date if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match_any', + value: ['2020-10-01T05:08:53.000Z'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + + it('should filter 2 dates if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match_any', + value: ['2020-10-01T05:08:53.000Z', '2020-10-02T05:08:53.000Z'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-03T05:08:53.000Z', '2020-10-04T05:08:53.000Z']); + }); + + it('should filter 3 dates if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match_any', + value: [ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + ], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-04T05:08:53.000Z']); + }); + + it('should filter 4 dates if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'match_any', + value: [ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'match_any', + value: ['2021-10-01T05:08:53.000Z', '2022-10-01T05:08:53.000Z'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'match_any', + value: ['2020-10-01T05:08:53.000Z', '2020-10-04T05:08:53.000Z'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-01T05:08:53.000Z', '2020-10-04T05:08:53.000Z']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against date', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against date', async () => { + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + }); + + describe('"is in list" operator', () => { + it('will return 3 results if we have a list that includes 1 date', async () => { + await importFile(supertest, 'date', ['2020-10-01T05:08:53.000Z'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + + it('will return 2 results if we have a list that includes 2 dates', async () => { + await importFile( + supertest, + 'date', + ['2020-10-01T05:08:53.000Z', '2020-10-03T05:08:53.000Z'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-02T05:08:53.000Z', '2020-10-04T05:08:53.000Z']); + }); + + it('will return 0 results if we have a list that includes all dates', async () => { + await importFile( + supertest, + 'date', + [ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 date', async () => { + await importFile(supertest, 'date', ['2020-10-01T05:08:53.000Z'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-01T05:08:53.000Z']); + }); + + it('will return 2 results if we have a list that excludes 2 dates', async () => { + await importFile( + supertest, + 'date', + ['2020-10-01T05:08:53.000Z', '2020-10-03T05:08:53.000Z'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql(['2020-10-01T05:08:53.000Z', '2020-10-03T05:08:53.000Z']); + }); + + it('will return 4 results if we have a list that excludes all dates', async () => { + await importFile( + supertest, + 'date', + [ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['date']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'date', + list: { + id: 'list_items.txt', + type: 'date', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.date).sort(); + expect(hits).to.eql([ + '2020-10-01T05:08:53.000Z', + '2020-10-02T05:08:53.000Z', + '2020-10-03T05:08:53.000Z', + '2020-10-04T05:08:53.000Z', + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts new file mode 100644 index 00000000000000..e29487880de6b6 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts @@ -0,0 +1,744 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type double', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/double'); + await esArchiver.load('rule_exceptions/double_as_string'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/double'); + await esArchiver.unload('rule_exceptions/double_as_string'); + }); + + describe('"is" operator', () => { + it('should find all the double from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + + it('should filter 1 single double if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('should filter 2 double if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.2', '1.3']); + }); + + it('should filter 3 double if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.3']); + }); + + it('should filter 4 double if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.2', + }, + ], + [ + { + field: 'double', + operator: 'included', + type: 'match', + value: '1.3', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'match', + value: '1.0', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 0 results if we exclude two double', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'double', + operator: 'excluded', + type: 'match', + value: '1.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single double if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match_any', + value: ['1.0'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('should filter 2 double if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.2', '1.3']); + }); + + it('should filter 3 double if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1', '1.2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.3']); + }); + + it('should filter 4 double if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1', '1.2', '1.3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'match_any', + value: ['1.0', '1.3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.3']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against double', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against double', async () => { + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + }); + + describe('"is in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a double against an index that has the doubles stored as real doubles. + describe.skip('working against double values in the data set', () => { + it('will return 3 results if we have a list that includes 1 double', async () => { + await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('will return 2 results if we have a list that includes 2 double', async () => { + await importFile(supertest, 'double', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.3']); + }); + + it('will return 0 results if we have a list that includes all double', async () => { + await importFile(supertest, 'double', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 3 results if we have a list that includes 1 double', async () => { + await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('will return 2 results if we have a list that includes 2 double', async () => { + await importFile(supertest, 'double', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.1', '1.3']); + }); + + it('will return 0 results if we have a list that includes all double', async () => { + await importFile(supertest, 'double', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql([]); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 1 result if we have a list which contains the double range of 1.0-1.2', async () => { + await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1.3']); + }); + }); + }); + + describe('"is not in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a double against an index that has the doubles stored as real doubles. + describe.skip('working against double values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 double', async () => { + await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 2 results if we have a list that excludes 2 double', async () => { + await importFile(supertest, 'double', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.2']); + }); + + it('will return 4 results if we have a list that excludes all double', async () => { + await importFile(supertest, 'double', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 double', async () => { + await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 2 results if we have a list that excludes 2 double', async () => { + await importFile(supertest, 'double', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.2']); + }); + + it('will return 4 results if we have a list that excludes all double', async () => { + await importFile(supertest, 'double', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'double', + list: { + id: 'list_items.txt', + type: 'double', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 3 results if we have a list which contains the double range of 1.0-1.2', async () => { + await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['double_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2']); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts new file mode 100644 index 00000000000000..d68f0f6a69277e --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts @@ -0,0 +1,744 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type float', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/float'); + await esArchiver.load('rule_exceptions/float_as_string'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/float'); + await esArchiver.unload('rule_exceptions/float_as_string'); + }); + + describe('"is" operator', () => { + it('should find all the float from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + + it('should filter 1 single float if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('should filter 2 float if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.2', '1.3']); + }); + + it('should filter 3 float if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.3']); + }); + + it('should filter 4 float if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.1', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.2', + }, + ], + [ + { + field: 'float', + operator: 'included', + type: 'match', + value: '1.3', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'match', + value: '1.0', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 0 results if we exclude two float', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'match', + value: '1.0', + }, + ], + [ + { + field: 'float', + operator: 'excluded', + type: 'match', + value: '1.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single float if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match_any', + value: ['1.0'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('should filter 2 float if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.2', '1.3']); + }); + + it('should filter 3 float if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1', '1.2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.3']); + }); + + it('should filter 4 float if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'match_any', + value: ['1.0', '1.1', '1.2', '1.3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'match_any', + value: ['1.0', '1.3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.3']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against float', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against float', async () => { + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + }); + + describe('"is in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a float against an index that has the floats stored as real floats. + describe.skip('working against float values in the data set', () => { + it('will return 3 results if we have a list that includes 1 float', async () => { + await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('will return 2 results if we have a list that includes 2 float', async () => { + await importFile(supertest, 'float', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.3']); + }); + + it('will return 0 results if we have a list that includes all float', async () => { + await importFile(supertest, 'float', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 3 results if we have a list that includes 1 float', async () => { + await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.2', '1.3']); + }); + + it('will return 2 results if we have a list that includes 2 float', async () => { + await importFile(supertest, 'float', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.1', '1.3']); + }); + + it('will return 0 results if we have a list that includes all float', async () => { + await importFile(supertest, 'float', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql([]); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 1 result if we have a list which contains the float range of 1.0-1.2', async () => { + await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1.3']); + }); + }); + }); + + describe('"is not in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a float against an index that has the floats stored as real floats. + describe.skip('working against float values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 float', async () => { + await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 2 results if we have a list that excludes 2 float', async () => { + await importFile(supertest, 'float', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.2']); + }); + + it('will return 4 results if we have a list that excludes all float', async () => { + await importFile(supertest, 'float', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 float', async () => { + await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0']); + }); + + it('will return 2 results if we have a list that excludes 2 float', async () => { + await importFile(supertest, 'float', ['1.0', '1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.2']); + }); + + it('will return 4 results if we have a list that excludes all float', async () => { + await importFile(supertest, 'float', ['1.0', '1.1', '1.2', '1.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'float', + list: { + id: 'list_items.txt', + type: 'float', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 3 results if we have a list which contains the float range of 1.0-1.2', async () => { + await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['float_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1.0', '1.1', '1.2']); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts new file mode 100644 index 00000000000000..d2aca34e27399a --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Detection exceptions data types and operators', function () { + this.tags('ciGroup1'); + + loadTestFile(require.resolve('./date')); + loadTestFile(require.resolve('./double')); + loadTestFile(require.resolve('./float')); + loadTestFile(require.resolve('./integer')); + loadTestFile(require.resolve('./ip')); + loadTestFile(require.resolve('./keyword')); + loadTestFile(require.resolve('./long')); + loadTestFile(require.resolve('./text')); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts new file mode 100644 index 00000000000000..9b38f0f7cbb42b --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts @@ -0,0 +1,744 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type integer', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/integer'); + await esArchiver.load('rule_exceptions/integer_as_string'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/integer'); + await esArchiver.unload('rule_exceptions/integer_as_string'); + }); + + describe('"is" operator', () => { + it('should find all the integer from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + + it('should filter 1 single integer if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('should filter 2 integer if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['3', '4']); + }); + + it('should filter 3 integer if all 3 are as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '2', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '3', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['4']); + }); + + it('should filter 4 integer if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '2', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '3', + }, + ], + [ + { + field: 'integer', + operator: 'included', + type: 'match', + value: '4', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'match', + value: '1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 0 results if we exclude two integer', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'integer', + operator: 'excluded', + type: 'match', + value: '2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single integer if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('should filter 2 integer if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['3', '4']); + }); + + it('should filter 3 integer if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match_any', + value: ['1', '2', '3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['4']); + }); + + it('should filter 4 integer if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'match_any', + value: ['1', '2', '3', '4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'match_any', + value: ['1', '4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '4']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against integer', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against integer', async () => { + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + }); + + describe('"is in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a integer against an index that has the integers stored as real integers. + describe.skip('working against integer values in the data set', () => { + it('will return 3 results if we have a list that includes 1 integer', async () => { + await importFile(supertest, 'integer', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('will return 2 results if we have a list that includes 2 integer', async () => { + await importFile(supertest, 'integer', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '4']); + }); + + it('will return 0 results if we have a list that includes all integer', async () => { + await importFile(supertest, 'integer', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 3 results if we have a list that includes 1 integer', async () => { + await importFile(supertest, 'integer', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('will return 2 results if we have a list that includes 2 integer', async () => { + await importFile(supertest, 'integer', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['2', '4']); + }); + + it('will return 0 results if we have a list that includes all integer', async () => { + await importFile(supertest, 'integer', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql([]); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 1 result if we have a list which contains the integer range of 1-3', async () => { + await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['4']); + }); + }); + }); + + describe('"is not in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a integer against an index that has the integers stored as real integers. + describe.skip('working against integer values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 integer', async () => { + await importFile(supertest, 'integer', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 2 results if we have a list that excludes 2 integer', async () => { + await importFile(supertest, 'integer', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '3']); + }); + + it('will return 4 results if we have a list that excludes all integer', async () => { + await importFile(supertest, 'integer', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 integer', async () => { + await importFile(supertest, 'integer', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 2 results if we have a list that excludes 2 integer', async () => { + await importFile(supertest, 'integer', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '3']); + }); + + it('will return 4 results if we have a list that excludes all integer', async () => { + await importFile(supertest, 'integer', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'integer', + list: { + id: 'list_items.txt', + type: 'integer', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 3 results if we have a list which contains the integer range of 1-3', async () => { + await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['integer_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1', '2', '3']); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts new file mode 100644 index 00000000000000..c3537efc12de77 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts @@ -0,0 +1,622 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type ip', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/ip'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/ip'); + }); + + describe('"is" operator', () => { + it('should find all the ips from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + + it('should filter 1 single ip if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + + it('should filter 2 ips if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.3', '127.0.0.4']); + }); + + it('should filter 3 ips if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.2', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.3', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + + it('should filter 4 ips if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.2', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.3', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.4', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + it('should filter a CIDR range of 127.0.0.1/30', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1/30', // CIDR IP Range is 127.0.0.0 - 127.0.0.3 + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '192.168.0.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1']); + }); + + it('will return 0 results if we exclude two ips', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single ip if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + + it('should filter 2 ips if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.3', '127.0.0.4']); + }); + + it('should filter 3 ips if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.2', '127.0.0.3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + + it('should filter 4 ips if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['192.168.0.1', '192.168.0.2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.4']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against ip', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against ip', async () => { + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + }); + + describe('"is in list" operator', () => { + it('will return 3 results if we have a list that includes 1 ip', async () => { + await importFile(supertest, 'ip', ['127.0.0.1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + + it('will return 2 results if we have a list that includes 2 ips', async () => { + await importFile(supertest, 'ip', ['127.0.0.1', '127.0.0.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.2', '127.0.0.4']); + }); + + it('will return 0 results if we have a list that includes all ips', async () => { + await importFile( + supertest, + 'ip', + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 1 result if we have a list which contains the CIDR range of 127.0.0.1/30', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 ip', async () => { + await importFile(supertest, 'ip', ['127.0.0.1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1']); + }); + + it('will return 2 results if we have a list that excludes 2 ips', async () => { + await importFile(supertest, 'ip', ['127.0.0.1', '127.0.0.3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.3']); + }); + + it('will return 4 results if we have a list that excludes all ips', async () => { + await importFile( + supertest, + 'ip', + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 3 results if we have a list which contains the CIDR range of 127.0.0.1/30', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3']); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts new file mode 100644 index 00000000000000..0c227c9acc38c8 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts @@ -0,0 +1,555 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type keyword', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/keyword'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/keyword'); + }); + + describe('"is" operator', () => { + it('should find all the keyword from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + + it('should filter 1 single keyword if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('should filter 2 keyword if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word three']); + }); + + it('should filter 3 keyword if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word three', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four']); + }); + + it('should filter 4 keyword if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word three', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word four', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word one']); + }); + + it('will return 0 results if we exclude two keyword', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word two', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single keyword if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('should filter 2 keyword if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word three']); + }); + + it('should filter 3 keyword if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one', 'word three', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four']); + }); + + it('should filter 4 keyword if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word four', 'word one', 'word three', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match_any', + value: ['word one', 'word four'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word one']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against keyword', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against keyword', async () => { + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + }); + + describe('"is in list" operator', () => { + it('will return 3 results if we have a list that includes 1 keyword', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('will return 2 results if we have a list that includes 2 keyword', async () => { + await importFile(supertest, 'keyword', ['word one', 'word three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word two']); + }); + + it('will return 0 results if we have a list that includes all keyword', async () => { + await importFile( + supertest, + 'keyword', + ['word one', 'word two', 'word three', 'word four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 keyword', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word one']); + }); + + it('will return 2 results if we have a list that excludes 2 keyword', async () => { + await importFile(supertest, 'keyword', ['word one', 'word three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word one', 'word three']); + }); + + it('will return 4 results if we have a list that excludes all keyword', async () => { + await importFile( + supertest, + 'keyword', + ['word one', 'word two', 'word three', 'word four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts new file mode 100644 index 00000000000000..5c110996c21984 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts @@ -0,0 +1,744 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type long', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/long'); + await esArchiver.load('rule_exceptions/long_as_string'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/long'); + await esArchiver.unload('rule_exceptions/long_as_string'); + }); + + describe('"is" operator', () => { + it('should find all the long from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + + it('should filter 1 single long if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('should filter 2 long if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['3', '4']); + }); + + it('should filter 3 long if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '2', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '3', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['4']); + }); + + it('should filter 4 long if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '2', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '3', + }, + ], + [ + { + field: 'long', + operator: 'included', + type: 'match', + value: '4', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'match', + value: '1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 0 results if we exclude two long', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'match', + value: '1', + }, + ], + [ + { + field: 'long', + operator: 'excluded', + type: 'match', + value: '2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single long if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('should filter 2 long if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['3', '4']); + }); + + it('should filter 3 long if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match_any', + value: ['1', '2', '3'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['4']); + }); + + it('should filter 4 long if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'match_any', + value: ['1', '2', '3', '4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'match_any', + value: ['1', '4'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '4']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against long', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against long', async () => { + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + }); + + describe('"is in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a long against an index that has the longs stored as real longs. + describe.skip('working against long values in the data set', () => { + it('will return 3 results if we have a list that includes 1 long', async () => { + await importFile(supertest, 'long', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('will return 2 results if we have a list that includes 2 long', async () => { + await importFile(supertest, 'long', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '4']); + }); + + it('will return 0 results if we have a list that includes all long', async () => { + await importFile(supertest, 'long', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 3 results if we have a list that includes 1 long', async () => { + await importFile(supertest, 'long', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '3', '4']); + }); + + it('will return 2 results if we have a list that includes 2 long', async () => { + await importFile(supertest, 'long', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['2', '4']); + }); + + it('will return 0 results if we have a list that includes all long', async () => { + await importFile(supertest, 'long', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql([]); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 1 result if we have a list which contains the long range of 1-3', async () => { + await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['4']); + }); + }); + }); + + describe('"is not in list" operator', () => { + // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent + // a long against an index that has the longs stored as real longs. + describe.skip('working against long values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 long', async () => { + await importFile(supertest, 'long', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 2 results if we have a list that excludes 2 long', async () => { + await importFile(supertest, 'long', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '3']); + }); + + it('will return 4 results if we have a list that excludes all long', async () => { + await importFile(supertest, 'long', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + }); + + describe('working against string values in the data set', () => { + it('will return 1 result if we have a list that excludes 1 long', async () => { + await importFile(supertest, 'long', ['1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1']); + }); + + it('will return 2 results if we have a list that excludes 2 long', async () => { + await importFile(supertest, 'long', ['1', '3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '3']); + }); + + it('will return 4 results if we have a list that excludes all long', async () => { + await importFile(supertest, 'long', ['1', '2', '3', '4'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'long', + list: { + id: 'list_items.txt', + type: 'long', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); + expect(hits).to.eql(['1', '2', '3', '4']); + }); + + // TODO: Fix this bug and then unskip this test + it.skip('will return 3 results if we have a list which contains the long range of 1-3', async () => { + await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['long_as_string']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(hits).to.eql(['1', '2', '3']); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts new file mode 100644 index 00000000000000..d2066b1023d3c2 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts @@ -0,0 +1,827 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, + importTextFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type text', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/text'); + await esArchiver.load('rule_exceptions/text_no_spaces'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/text'); + await esArchiver.unload('rule_exceptions/text_no_spaces'); + }); + + describe('"is" operator', () => { + it('should find all the text from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + + it('should filter 1 single text if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('should filter 2 text if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three']); + }); + + it('should filter 3 text if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word three', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four']); + }); + + it('should filter 4 text if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word two', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word three', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word four', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('should filter 1 single text using a single word', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('should filter all words using a common piece of text', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('should filter 1 single text with punctuation added', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'one.', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one']); + }); + + it('will return 0 results if we exclude two text', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word two', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('should filter 1 single text using a single word', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one']); + }); + + it('should filter all words using a common piece of text', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + + it('should filter 1 single text with punctuation added', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'one.', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one']); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single text if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('should filter 2 text if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three']); + }); + + it('should filter 3 text if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one', 'word three', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four']); + }); + + it('should filter 4 text if all are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word four', 'word one', 'word three', 'word two'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match_any', + value: ['word one', 'word four'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word one']); + }); + }); + + describe('"exists" operator', () => { + it('will return 0 results if matching against text', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 4 results if matching against text', async () => { + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + }); + + describe('"is in list" operator', () => { + describe('working against text values without spaces', () => { + it('will return 3 results if we have a list that includes 1 text', async () => { + await importFile(supertest, 'text', ['one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['four', 'three', 'two']); + }); + + it('will return 2 results if we have a list that includes 2 text', async () => { + await importFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['four', 'two']); + }); + + it('will return 0 results if we have a list that includes all text', async () => { + await importTextFile( + supertest, + 'text', + ['one', 'two', 'three', 'four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + }); + + // TODO: Unskip these once this is fixed + describe.skip('working against text values with spaces', () => { + it('will return 3 results if we have a list that includes 1 text', async () => { + await importFile(supertest, 'text', ['one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('will return 2 results if we have a list that includes 2 text', async () => { + await importFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word two']); + }); + + it('will return 0 results if we have a list that includes all text', async () => { + await importTextFile( + supertest, + 'text', + ['one', 'two', 'three', 'four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + }); + }); + + describe('"is not in list" operator', () => { + describe('working against text values without spaces', () => { + it('will return 1 result if we have a list that excludes 1 text', async () => { + await importTextFile(supertest, 'text', ['one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['one']); + }); + + it('will return 2 results if we have a list that excludes 2 text', async () => { + await importTextFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['one', 'three']); + }); + + it('will return 4 results if we have a list that excludes all text', async () => { + await importTextFile( + supertest, + 'text', + ['one', 'two', 'three', 'four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['text_no_spaces']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['four', 'one', 'three', 'two']); + }); + }); + + // TODO: Unskip these once this is fixed + describe.skip('working against text values with spaces', () => { + it('will return 1 result if we have a list that excludes 1 text', async () => { + await importTextFile(supertest, 'text', ['one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one']); + }); + + it('will return 2 results if we have a list that excludes 2 text', async () => { + await importTextFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one', 'word three']); + }); + + it('will return 4 results if we have a list that excludes all text', async () => { + await importTextFile( + supertest, + 'text', + ['one', 'two', 'three', 'four'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts index 2610796bdc384b..4f76a0544a152a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts @@ -22,7 +22,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('export_rules', () => { describe('exporting rules', () => { @@ -32,7 +31,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should set the response content types to be expected', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts index f496d035d8e606..2f06a84c7223bc 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('find_rules', () => { beforeEach(async () => { @@ -32,7 +31,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should return an empty find body correctly if no rules are loaded', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts index fac1fbaaf96758..8bb4c45d91bdd6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts @@ -30,7 +30,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllRulesStatuses(es); }); @@ -64,7 +64,7 @@ export default ({ getService }: FtrProviderContext): void => { this pops up again elsewhere. */ it('should return a single rule status when a single rule is loaded from a find status with defaults added', async () => { - const resBody = await createRule(supertest, getSimpleRule()); + const resBody = await createRule(supertest, getSimpleRule('rule-1', true)); await waitForRuleSuccess(supertest, resBody.id); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index f76bdb4ebc718d..0db3013503a33f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -17,9 +17,11 @@ import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, - getAllSignals, + getRuleForSignalTesting, + getSignalsByIds, getSignalsByRuleIds, getSimpleRule, + waitForRuleSuccess, waitForSignalsToBePresent, } from '../../utils'; @@ -33,17 +35,15 @@ export const ID = 'BhbXBmkBR346wHgn4PeZ'; export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); describe('Generating signals from source indexes', () => { beforeEach(async () => { - await deleteAllAlerts(es); await createSignalsIndex(supertest); }); afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); describe('Signals from audit beat are of the expected structure', () => { @@ -57,37 +57,37 @@ export default ({ getService }: FtrProviderContext) => { it('should have the specific audit record for _id or none of these tests below will pass', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), query: `_id:${ID}`, }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).greaterThan(0); }); it('should have recorded the rule_id within the signal', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), query: `_id:${ID}`, }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits[0]._source.signal.rule.rule_id).eql(getSimpleRule().rule_id); }); it('should query and get back expected signal structure using a basic KQL query', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), query: `_id:${ID}`, }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); // remove rule to cut down on touch points for test changes when the rule format changes const { rule: removedRule, ...signalNoRule } = signalsOpen.hits.hits[0]._source.signal; expect(signalNoRule).eql({ @@ -126,25 +126,23 @@ export default ({ getService }: FtrProviderContext) => { }); it('should query and get back expected signal structure when it is a signal on a signal', async () => { - // create a 1 signal from 1 auditbeat record const rule: QueryCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), query: `_id:${ID}`, }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); + const { id: createdId } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, createdId); + await waitForSignalsToBePresent(supertest, 1, [createdId]); // Run signals on top of that 1 signal which should create a single signal (on top of) a signal const ruleForSignals: QueryCreateSchema = { - ...getSimpleRule(), + ...getRuleForSignalTesting([`${DEFAULT_SIGNALS_INDEX}*`]), rule_id: 'signal-on-signal', - index: [`${DEFAULT_SIGNALS_INDEX}*`], - from: '1900-01-01T00:00:00.000Z', - query: '*:*', }; - await createRule(supertest, ruleForSignals); - await waitForSignalsToBePresent(supertest, 2); + + const { id } = await createRule(supertest, ruleForSignals); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); // Get our single signal on top of a signal const signalsOpen = await getSignalsByRuleIds(supertest, ['signal-on-signal']); @@ -198,15 +196,15 @@ export default ({ getService }: FtrProviderContext) => { describe('EQL Rules', () => { it('generates signals from EQL sequences in the expected form', async () => { const rule: EqlCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), rule_id: 'eql-rule', type: 'eql', language: 'eql', query: 'sequence by host.name [any where true] [any where true]', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); const signals = await getSignalsByRuleIds(supertest, ['eql-rule']); const signal = signals.hits.hits[0]._source.signal; @@ -250,15 +248,15 @@ export default ({ getService }: FtrProviderContext) => { it('generates building block signals from EQL sequences in the expected form', async () => { const rule: EqlCreateSchema = { - ...getSimpleRule(), - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['auditbeat-*']), rule_id: 'eql-rule', type: 'eql', language: 'eql', query: 'sequence by host.name [any where true] [any where true]', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsByRuleIds(supertest, ['eql-rule']); const sequenceSignal = signalsOpen.hits.hits.find( (signal) => signal._source.signal.depth === 2 @@ -337,40 +335,39 @@ export default ({ getService }: FtrProviderContext) => { it('should have the specific audit record for _id or none of these tests below will pass', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_name_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_name_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).greaterThan(0); }); it('should have recorded the rule_id within the signal', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_name_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_name_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits[0]._source.signal.rule.rule_id).eql(getSimpleRule().rule_id); }); it('should query and get back expected signal structure using a basic KQL query', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_name_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_name_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); // remove rule to cut down on touch points for test changes when the rule format changes const { rule: removedRule, ...signalNoRule } = signalsOpen.hits.hits[0]._source.signal; expect(signalNoRule).eql({ @@ -404,26 +401,22 @@ export default ({ getService }: FtrProviderContext) => { }); it('should query and get back expected signal structure when it is a signal on a signal', async () => { - // create a 1 signal from 1 auditbeat record const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_name_clash'], - from: '1900-01-01T00:00:00.000Z', - query: `_id:1`, + ...getRuleForSignalTesting(['signal_name_clash']), + query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); // Run signals on top of that 1 signal which should create a single signal (on top of) a signal const ruleForSignals: QueryCreateSchema = { - ...getSimpleRule(), + ...getRuleForSignalTesting([`${DEFAULT_SIGNALS_INDEX}*`]), rule_id: 'signal-on-signal', - index: [`${DEFAULT_SIGNALS_INDEX}*`], - from: '1900-01-01T00:00:00.000Z', - query: '*:*', }; - await createRule(supertest, ruleForSignals); - await waitForSignalsToBePresent(supertest, 2); + const { id: createdId } = await createRule(supertest, ruleForSignals); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [createdId]); // Get our single signal on top of a signal const signalsOpen = await getSignalsByRuleIds(supertest, ['signal-on-signal']); @@ -479,7 +472,7 @@ export default ({ getService }: FtrProviderContext) => { * You should see the "signal" object/clash being copied to "original_signal" underneath * the signal object and no errors when they do have a clash. */ - describe('Signals generated from name clashes', () => { + describe('Signals generated from object clashes', () => { beforeEach(async () => { await esArchiver.load('signals/object_clash'); }); @@ -490,40 +483,37 @@ export default ({ getService }: FtrProviderContext) => { it('should have the specific audit record for _id or none of these tests below will pass', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_object_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_object_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).greaterThan(0); }); it('should have recorded the rule_id within the signal', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_object_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_object_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits[0]._source.signal.rule.rule_id).eql(getSimpleRule().rule_id); }); it('should query and get back expected signal structure using a basic KQL query', async () => { const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_object_clash'], - from: '1900-01-01T00:00:00.000Z', + ...getRuleForSignalTesting(['signal_object_clash']), query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); - const signalsOpen = await getAllSignals(supertest); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); // remove rule to cut down on touch points for test changes when the rule format changes const { rule: removedRule, ...signalNoRule } = signalsOpen.hits.hits[0]._source.signal; expect(signalNoRule).eql({ @@ -563,26 +553,22 @@ export default ({ getService }: FtrProviderContext) => { }); it('should query and get back expected signal structure when it is a signal on a signal', async () => { - // create a 1 signal from 1 auditbeat record const rule: QueryCreateSchema = { - ...getSimpleRule(), - index: ['signal_object_clash'], - from: '1900-01-01T00:00:00.000Z', - query: `_id:1`, + ...getRuleForSignalTesting(['signal_object_clash']), + query: '_id:1', }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 1); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); // Run signals on top of that 1 signal which should create a single signal (on top of) a signal const ruleForSignals: QueryCreateSchema = { - ...getSimpleRule(), + ...getRuleForSignalTesting([`${DEFAULT_SIGNALS_INDEX}*`]), rule_id: 'signal-on-signal', - index: [`${DEFAULT_SIGNALS_INDEX}*`], - from: '1900-01-01T00:00:00.000Z', - query: '*:*', }; - await createRule(supertest, ruleForSignals); - await waitForSignalsToBePresent(supertest, 2); + const { id: createdId } = await createRule(supertest, ruleForSignals); + await waitForRuleSuccess(supertest, createdId); + await waitForSignalsToBePresent(supertest, 1, [createdId]); // Get our single signal on top of a signal const signalsOpen = await getSignalsByRuleIds(supertest, ['signal-on-signal']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts index 1bbfce42d2baad..c72b2e50b39fcf 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts @@ -32,7 +32,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await deleteAllTimelines(es); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 664077d5a4fab9..4ae953ead9df7e 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('es'); describe('import_rules', () => { describe('importing rules without an index', () => { @@ -39,7 +38,7 @@ export default ({ getService }: FtrProviderContext): void => { .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) .send(); return body.status_code === 404; - }); + }, `within should not create a rule if the index does not exist, ${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`); // Try to fetch the rule which should still be a 404 (not found) const { body } = await supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`).send(); @@ -86,7 +85,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should set the response content types to be expected', async () => { @@ -129,7 +128,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') .expect(200); const { body } = await supertest @@ -138,7 +137,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1')); + expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1', false)); }); it('should be able to import two rules', async () => { @@ -243,7 +242,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') .expect(200); const simpleRule = getSimpleRule('rule-1'); @@ -335,17 +334,13 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2'], true), 'rules.ndjson') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') .expect(200); await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') - .attach( - 'file', - getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3'], true), - 'rules.ndjson' - ) + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') .expect(200); const { body: bodyOfRule1 } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 962ae53b1241f0..97d5b079fd2069 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -19,6 +19,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./create_exceptions')); loadTestFile(require.resolve('./delete_rules')); loadTestFile(require.resolve('./delete_rules_bulk')); + loadTestFile(require.resolve('./exception_operators_data_types/index')); loadTestFile(require.resolve('./export_rules')); loadTestFile(require.resolve('./find_rules')); loadTestFile(require.resolve('./find_statuses')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts index bbc3943b75955b..87e3b145ed6fd6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts @@ -18,12 +18,13 @@ import { deleteSignalsIndex, setSignalStatus, getSignalStatusEmptyResponse, - getSimpleRule, getQuerySignalIds, deleteAllAlerts, createRule, waitForSignalsToBePresent, - getAllSignals, + getSignalsByIds, + waitForRuleSuccess, + getRuleForSignalTesting, } from '../../utils'; import { createUserAndRole } from '../roles_users_utils'; import { ROLES } from '../../../../plugins/security_solution/common/test'; @@ -32,7 +33,6 @@ import { ROLES } from '../../../../plugins/security_solution/common/test'; export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const securityService = getService('security'); @@ -69,29 +69,31 @@ export default ({ getService }: FtrProviderContext) => { describe('tests with auditbeat data', () => { beforeEach(async () => { - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await createSignalsIndex(supertest); await esArchiver.load('auditbeat/hosts'); }); afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); await esArchiver.unload('auditbeat/hosts'); }); it('should be able to execute and get 10 signals', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).equal(10); }); it('should be have set the signals in an open state initially', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const everySignalOpen = signalsOpen.hits.hits.every( ({ _source: { @@ -103,10 +105,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able to get a count of 10 closed signals when closing 10', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest, 10); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 10, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // set all of the signals to the state of closed. There is no reason to use a waitUntil here @@ -129,10 +132,11 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able close signals immediately and they all should be closed', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); - const signalsOpen = await getAllSignals(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // set all of the signals to the state of closed. There is no reason to use a waitUntil here @@ -163,11 +167,12 @@ export default ({ getService }: FtrProviderContext) => { }); it('should NOT be able to close signals with t1 analyst user', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); await createUserAndRole(securityService, ROLES.t1_analyst); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // Try to set all of the signals to the state of closed. @@ -200,12 +205,13 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able to close signals with soc_manager user', async () => { - const rule = { ...getSimpleRule(), from: '1900-01-01T00:00:00.000Z', query: '*:*' }; - await createRule(supertest, rule); - await waitForSignalsToBePresent(supertest); + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); const userAndRole = ROLES.soc_manager; await createUserAndRole(securityService, userAndRole); - const signalsOpen = await getAllSignals(supertest); + const signalsOpen = await getSignalsByIds(supertest, [id]); const signalIds = signalsOpen.hits.hits.map((signal) => signal._id); // Try to set all of the signals to the state of closed. diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts index dbe66741e06c7b..4de8abefe16fc1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts @@ -25,7 +25,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('patch_rules', () => { describe('patch rules', () => { @@ -35,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should patch a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts index 69330a2bf682a2..e32771d0d917c7 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts @@ -23,7 +23,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('patch_rules_bulk', () => { describe('patch rules bulk', () => { @@ -33,7 +32,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should patch a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts index cfccb7436ea207..1697554441c16a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('read_rules', () => { describe('reading rules', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should be able to read a single rule using rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts index 23a8776b14631d..59dbe97557157b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts @@ -27,7 +27,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('update_rules', () => { describe('update rules', () => { @@ -37,7 +36,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should update a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts index 22aa40b0721a43..c5b65039aa1164 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts @@ -24,7 +24,6 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('es'); describe('update_rules_bulk', () => { describe('update rules bulk', () => { @@ -34,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { afterEach(async () => { await deleteSignalsIndex(supertest); - await deleteAllAlerts(es); + await deleteAllAlerts(supertest); }); it('should update a single rule property of name using a rule_id', async () => { diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index f458fe118dcf7d..06d33da8f1f555 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -9,6 +9,8 @@ import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; import { Context } from '@elastic/elasticsearch/lib/Transport'; import { SearchResponse } from 'elasticsearch'; +import { NonEmptyEntriesArray } from '../../plugins/lists/common/schemas'; +import { getCreateExceptionListDetectionSchemaMock } from '../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { CreateRulesSchema, UpdateRulesSchema, @@ -35,6 +37,7 @@ import { DETECTION_ENGINE_RULES_URL, INTERNAL_RULE_ID_KEY, } from '../../plugins/security_solution/common/constants'; +import { getCreateExceptionListItemMinimalSchemaMockWithoutId } from '../../plugins/lists/common/schemas/request/create_exception_list_item_schema.mock'; /** * This will remove server generated properties such as date times, etc... @@ -76,9 +79,9 @@ export const removeServerGeneratedPropertiesIncludingRuleId = ( /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId - * @param enabled Enables the rule on creation or not. Defaulted to false to enable it on import + * @param enabled Enables the rule on creation or not. Defaulted to true. */ -export const getSimpleRule = (ruleId = 'rule-1', enabled = true): QueryCreateSchema => ({ +export const getSimpleRule = (ruleId = 'rule-1', enabled = false): QueryCreateSchema => ({ name: 'Simple Rule Query', description: 'Simple Rule Query', enabled, @@ -90,13 +93,39 @@ export const getSimpleRule = (ruleId = 'rule-1', enabled = true): QueryCreateSch query: 'user.name: root or user.name: admin', }); +/** + * This is a typical signal testing rule that is easy for most basic testing of output of signals. + * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * creation and testing by getting all the signals at once. + * @param ruleId The optional ruleId which is rule-1 by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getRuleForSignalTesting = ( + index: string[], + ruleId = 'rule-1', + enabled = true +): QueryCreateSchema => ({ + name: 'Signal Testing Query', + description: 'Tests a simple query', + enabled, + risk_score: 1, + rule_id: ruleId, + severity: 'high', + index, + type: 'query', + query: '*:*', + from: '1900-01-01T00:00:00.000Z', +}); + /** * This is a typical simple rule for testing that is easy for most basic testing - * @param ruleId + * @param ruleId The rule id + * @param enabled Set to tru to enable it, by default it is off */ -export const getSimpleRuleUpdate = (ruleId = 'rule-1'): UpdateRulesSchema => ({ +export const getSimpleRuleUpdate = (ruleId = 'rule-1', enabled = false): UpdateRulesSchema => ({ name: 'Simple Rule Query', description: 'Simple Rule Query', + enabled, risk_score: 1, rule_id: ruleId, severity: 'high', @@ -107,11 +136,13 @@ export const getSimpleRuleUpdate = (ruleId = 'rule-1'): UpdateRulesSchema => ({ /** * This is a representative ML rule payload as expected by the server - * @param ruleId + * @param ruleId The rule id + * @param enabled Set to tru to enable it, by default it is off */ -export const getSimpleMlRule = (ruleId = 'rule-1'): CreateRulesSchema => ({ +export const getSimpleMlRule = (ruleId = 'rule-1', enabled = false): CreateRulesSchema => ({ name: 'Simple ML Rule', description: 'Simple Machine Learning Rule', + enabled, anomaly_threshold: 44, risk_score: 1, rule_id: ruleId, @@ -120,9 +151,15 @@ export const getSimpleMlRule = (ruleId = 'rule-1'): CreateRulesSchema => ({ type: 'machine_learning', }); -export const getSimpleMlRuleUpdate = (ruleId = 'rule-1'): UpdateRulesSchema => ({ +/** + * This is a representative ML rule payload as expected by the server for an update + * @param ruleId The rule id + * @param enabled Set to tru to enable it, by default it is off + */ +export const getSimpleMlRuleUpdate = (ruleId = 'rule-1', enabled = false): UpdateRulesSchema => ({ name: 'Simple ML Rule', description: 'Simple Machine Learning Rule', + enabled, anomaly_threshold: 44, risk_score: 1, rule_id: ruleId, @@ -160,6 +197,19 @@ export const getQuerySignalsRuleId = (ruleIds: string[]) => ({ }, }); +/** + * Given an array of ids for a test this will get the signals + * created from that rule's regular id. + * @param ruleIds The rule_id to search for signals + */ +export const getQuerySignalsId = (ids: string[]) => ({ + query: { + terms: { + 'signal.rule.id': ids, + }, + }, +}); + export const setSignalStatus = ({ signalIds, status, @@ -216,12 +266,12 @@ export const binaryToString = (res: any, callback: any): void => { * This is the typical output of a simple rule that Kibana will output with all the defaults * except for the server generated properties. Useful for testing end to end tests. */ -export const getSimpleRuleOutput = (ruleId = 'rule-1'): Partial<RulesSchema> => ({ +export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial<RulesSchema> => ({ actions: [], author: [], created_by: 'elastic', description: 'Simple Rule Query', - enabled: true, + enabled, false_positives: [], from: 'now-6m', immutable: false, @@ -274,21 +324,38 @@ export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial<RulesSchema> = }; /** - * Remove all alerts from the .kibana index - * This will retry 20 times before giving up and hopefully still not interfere with other tests - * @param es The ElasticSearch handle + * Removes all rules by looping over any found and removing them from REST. + * @param supertest The supertest agent. */ -export const deleteAllAlerts = async (es: Client): Promise<void> => { - return countDownES(async () => { - return es.deleteByQuery({ - index: '.kibana', - q: 'type:alert', - wait_for_completion: true, - refresh: true, - conflicts: 'proceed', - body: {}, - }); - }, 'deleteAllAlerts'); +export const deleteAllAlerts = async ( + supertest: SuperTest<supertestAsPromised.Test> +): Promise<void> => { + await countDownTest( + async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find?per_page=9999`) + .set('kbn-xsrf', 'true') + .send(); + + const ids = body.data.map((rule: FullResponseSchema) => ({ + id: rule.id, + })); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send(ids) + .set('kbn-xsrf', 'true'); + + const { body: finalCheck } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find`) + .set('kbn-xsrf', 'true') + .send(); + return finalCheck.data.length === 0; + }, + 'deleteAllAlerts', + 50, + 1000 + ); }; export const downgradeImmutableRule = async (es: Client, ruleId: string): Promise<void> => { @@ -331,7 +398,7 @@ export const deleteAllTimelines = async (es: Client): Promise<void> => { * This will retry 20 times before giving up and hopefully still not interfere with other tests * @param es The ElasticSearch handle */ -export const deleteAllRulesStatuses = async (es: Client, retryCount = 20): Promise<void> => { +export const deleteAllRulesStatuses = async (es: Client): Promise<void> => { return countDownES(async () => { return es.deleteByQuery({ index: '.kibana', @@ -585,8 +652,8 @@ export const getWebHookAction = () => ({ name: 'Some connector', }); -export const getRuleWithWebHookAction = (id: string): CreateRulesSchema => ({ - ...getSimpleRule(), +export const getRuleWithWebHookAction = (id: string, enabled = false): CreateRulesSchema => ({ + ...getSimpleRule('rule-1', enabled), throttle: 'rule', actions: [ { @@ -618,7 +685,8 @@ export const getSimpleRuleOutputWithWebHookAction = (actionId: string): Partial< // Similar to ReactJs's waitFor from here: https://testing-library.com/docs/dom-testing-library/api-async#waitfor export const waitFor = async ( functionToTest: () => Promise<boolean>, - maxTimeout: number = 5000, + functionName: string, + maxTimeout: number = 10000, timeoutWait: number = 10 ): Promise<void> => { await new Promise(async (resolve, reject) => { @@ -636,7 +704,9 @@ export const waitFor = async ( if (found) { resolve(); } else { - reject(new Error('timed out waiting for function condition to be true')); + reject( + new Error(`timed out waiting for function condition to be true within ${functionName}`) + ); } }); }; @@ -807,7 +877,7 @@ export const waitForRuleSuccess = async ( .send({ ids: [id] }) .expect(200); return body[id]?.current_status?.status === 'succeeded'; - }); + }, 'waitForRuleSuccess'); }; /** @@ -818,51 +888,77 @@ export const waitForRuleSuccess = async ( */ export const waitForSignalsToBePresent = async ( supertest: SuperTest<supertestAsPromised.Test>, - numberOfSignals = 1 + numberOfSignals = 1, + signalIds: string[] ): Promise<void> => { await waitFor(async () => { - const { - body: signalsOpen, - }: { body: SearchResponse<{ signal: Signal }> } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQueryAllSignals()) - .expect(200); + const signalsOpen = await getSignalsByIds(supertest, signalIds); return signalsOpen.hits.hits.length >= numberOfSignals; - }); + }, 'waitForSignalsToBePresent'); }; /** - * Returns all signals both closed and opened + * Returns all signals both closed and opened by ruleId * @param supertest Deps */ -export const getAllSignals = async ( - supertest: SuperTest<supertestAsPromised.Test> +export const getSignalsByRuleIds = async ( + supertest: SuperTest<supertestAsPromised.Test>, + ruleIds: string[] ): Promise< SearchResponse<{ signal: Signal; + [x: string]: unknown; }> > => { const { body: signalsOpen }: { body: SearchResponse<{ signal: Signal }> } = await supertest .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) .set('kbn-xsrf', 'true') - .send(getQueryAllSignals()) + .send(getQuerySignalsRuleId(ruleIds)) .expect(200); return signalsOpen; }; -export const getSignalsByRuleIds = async ( +/** + * Given an array of rule ids this will return only signals based on that rule id both + * open and closed + * @param supertest agent + * @param ids Array of the rule ids + */ +export const getSignalsByIds = async ( supertest: SuperTest<supertestAsPromised.Test>, - ruleIds: string[] + ids: string[] ): Promise< SearchResponse<{ signal: Signal; + [x: string]: unknown; }> > => { const { body: signalsOpen }: { body: SearchResponse<{ signal: Signal }> } = await supertest .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) .set('kbn-xsrf', 'true') - .send(getQuerySignalsRuleId(ruleIds)) + .send(getQuerySignalsId(ids)) + .expect(200); + return signalsOpen; +}; + +/** + * Given a single rule id this will return only signals based on that rule id. + * @param supertest agent + * @param ids Rule id + */ +export const getSignalsById = async ( + supertest: SuperTest<supertestAsPromised.Test>, + id: string +): Promise< + SearchResponse<{ + signal: Signal; + [x: string]: unknown; + }> +> => { + const { body: signalsOpen }: { body: SearchResponse<{ signal: Signal }> } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalsId([id])) .expect(200); return signalsOpen; }; @@ -870,5 +966,77 @@ export const getSignalsByRuleIds = async ( export const installPrePackagedRules = async ( supertest: SuperTest<supertestAsPromised.Test> ): Promise<void> => { - await supertest.put(DETECTION_ENGINE_PREPACKAGED_URL).set('kbn-xsrf', 'true').send().expect(200); + await countDownTest(async () => { + const { status } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send(); + return status === 200; + }, 'installPrePackagedRules'); +}; + +/** + * Convenience testing function where you can pass in just the entries and you will + * get a rule created with the entries added to an exception list and exception list item + * all auto-created at once. + * @param supertest super test agent + * @param rule The rule to create and attach an exception list to + * @param entries The entries to create the rule and exception list from + */ +export const createRuleWithExceptionEntries = async ( + supertest: SuperTest<supertestAsPromised.Test>, + rule: QueryCreateSchema, + entries: NonEmptyEntriesArray[] +): Promise<FullResponseSchema> => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, list_id, namespace_type, type } = await createExceptionList( + supertest, + getCreateExceptionListDetectionSchemaMock() + ); + + await Promise.all( + entries.map((entry) => { + const exceptionListItem: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemMinimalSchemaMockWithoutId(), + entries: entry, + }; + return createExceptionListItem(supertest, exceptionListItem); + }) + ); + + // To reduce the odds of in-determinism and/or bugs we ensure we have + // the same length of entries before continuing. + await waitFor(async () => { + const { body } = await supertest.get( + `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${ + getCreateExceptionListDetectionSchemaMock().list_id + }` + ); + return body.data.length === entries.length; + }, `within createRuleWithExceptionEntries ${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${getCreateExceptionListDetectionSchemaMock().list_id}`); + + // create the rule but don't run it immediately as running it immediately can cause + // the rule to sometimes not filter correctly the first time with an exception list + // or other timing issues. Then afterwards wait for the rule to have succeeded before + // returning. + const ruleWithException: QueryCreateSchema = { + ...rule, + enabled: false, + exceptions_list: [ + { + id, + list_id, + namespace_type, + type, + }, + ], + }; + const ruleResponse = await createRule(supertest, ruleWithException); + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: ruleResponse.rule_id, enabled: true }) + .expect(200); + + return ruleResponse; }; diff --git a/x-pack/test/fleet_api_integration/apis/epm/get.ts b/x-pack/test/fleet_api_integration/apis/epm/get.ts index c6de3a7f2b9dc0..53982affa128c9 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/get.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/get.ts @@ -4,16 +4,73 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; +import fs from 'fs'; +import path from 'path'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { warnAndSkipTest } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const log = getService('log'); const supertest = getService('supertest'); const dockerServers = getService('dockerServers'); const server = dockerServers.get('registry'); + const testPkgKey = 'apache-0.1.4'; + + const uninstallPackage = async (pkg: string) => { + await supertest.delete(`/api/fleet/epm/packages/${pkg}`).set('kbn-xsrf', 'xxxx'); + }; + const installPackage = async (pkg: string) => { + await supertest + .post(`/api/fleet/epm/packages/${pkg}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); + }; + + const testPkgArchiveZip = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_0.1.4.zip' + ); + describe('EPM - get', () => { + it('returns package info from the registry if it was installed from the registry', async function () { + if (server.enabled) { + // this will install through the registry by default + await installPackage(testPkgKey); + const res = await supertest.get(`/api/fleet/epm/packages/${testPkgKey}`).expect(200); + const packageInfo = res.body.response; + // the uploaded version will have this description + expect(packageInfo.description).to.not.equal('Apache Uploaded Test Integration'); + // download property should exist + expect(packageInfo.download).to.not.equal(undefined); + await uninstallPackage(testPkgKey); + } else { + warnAndSkipTest(this, log); + } + }); + it('returns correct package info if it was installed by upload', async function () { + if (server.enabled) { + const buf = fs.readFileSync(testPkgArchiveZip); + await supertest + .post(`/api/fleet/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(200); + + const res = await supertest.get(`/api/fleet/epm/packages/${testPkgKey}`).expect(200); + const packageInfo = res.body.response; + // the uploaded version will have this description + expect(packageInfo.description).to.equal('Apache Uploaded Test Integration'); + // download property should not exist on uploaded packages + expect(packageInfo.download).to.equal(undefined); + await uninstallPackage(testPkgKey); + } else { + warnAndSkipTest(this, log); + } + }); it('returns a 500 for a package key without a proper name', async function () { if (server.enabled) { await supertest.get('/api/fleet/epm/packages/-0.1.0').expect(500); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts index a5f1aa8003f044..885386b092108d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts @@ -80,7 +80,7 @@ export default function (providerContext: FtrProviderContext) { .type('application/zip') .send(buf) .expect(200); - expect(res.body.response.length).to.be(18); + expect(res.body.response.length).to.be(23); }); it('should throw an error if the archive is zip but content type is gzip', async function () { diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz index 9cc4009d35c31d..b1f2ac6797fb38 100644 Binary files a/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz and b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.tar.gz differ diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip index 410b00ecde2be5..2095ed0dba3456 100644 Binary files a/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip and b/x-pack/test/fleet_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip differ diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts b/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts index 288804750277ea..768bfb3a69fdf1 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts @@ -23,7 +23,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); - describe('Explore underlying data - panel action', function () { + // FLAKY: https://github.com/elastic/kibana/issues/84011 + // FLAKY: https://github.com/elastic/kibana/issues/84012 + describe.skip('Explore underlying data - panel action', function () { before( 'change default index pattern to verify action navigates to correct index pattern', async () => { diff --git a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js index 10754d20118e9b..d612a3776d2115 100644 --- a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js +++ b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { - const PageObjects = getPageObjects(['common', 'dashboard', 'maps']); + const PageObjects = getPageObjects(['common', 'dashboard', 'discover', 'maps']); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); @@ -48,16 +48,11 @@ export default function ({ getPageObjects, getService }) { }); describe('panel actions', () => { - before(async () => { + beforeEach(async () => { await loadDashboardAndOpenTooltip(); }); - it('should display more actions button when tooltip is locked', async () => { - const exists = await testSubjects.exists('mapTooltipMoreActionsButton'); - expect(exists).to.be(true); - }); - - it('should trigger drilldown action when clicked', async () => { + it('should trigger dashboard drilldown action when clicked', async () => { await testSubjects.click('mapTooltipMoreActionsButton'); await testSubjects.click('mapFilterActionButton__drilldown1'); @@ -69,6 +64,16 @@ export default function ({ getPageObjects, getService }) { const hasJoinFilter = await filterBar.hasFilter('shape_name', 'charlie'); expect(hasJoinFilter).to.be(true); }); + + it('should trigger url drilldown action when clicked', async () => { + await testSubjects.click('mapTooltipMoreActionsButton'); + await testSubjects.click('mapFilterActionButton__urlDrilldownToDiscover'); + + // Assert on discover with filter from action + await PageObjects.discover.waitForDiscoverAppOnScreen(); + const hasFilter = await filterBar.hasFilter('name', 'charlie'); + expect(hasFilter).to.be(true); + }); }); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index 1557d2b4ec2fbb..c759f22d0396c8 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -88,7 +88,7 @@ export default function ({ getService }: FtrProviderContext) { } }); - describe('with no data loaded', function () { + describe('with data loaded', function () { const adJobId = 'fq_single_permission'; const dfaJobId = 'iph_outlier_permission'; const calendarId = 'calendar_permission'; diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 79e8c14cc39827..71b4a85d63f089 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1113,7 +1113,7 @@ "title" : "dash for tooltip filter action test", "hits" : 0, "description" : "Zoomed in so entire screen is covered by filter so click to open tooltip can not miss.", - "panelsJSON" : "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"],\"hiddenLayers\":[],\"enhancements\":{\"dynamicActions\":{\"events\":[{\"eventId\":\"669a3521-1215-4228-9ced-77e2edf5ad17\",\"triggers\":[\"FILTER_TRIGGER\"],\"action\":{\"name\":\"drilldown1\",\"config\":{\"dashboardId\":\"19906970-2e40-11e9-85cb-6965aae20f13\",\"useCurrentFilters\":true,\"useCurrentDateRange\":true},\"factoryId\":\"DASHBOARD_TO_DASHBOARD_DRILLDOWN\"}}]}}},\"panelRefName\":\"panel_0\"}]", + "panelsJSON" : "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"],\"hiddenLayers\":[],\"enhancements\":{\"dynamicActions\":{\"events\":[{\"eventId\":\"669a3521-1215-4228-9ced-77e2edf5ad17\",\"triggers\":[\"FILTER_TRIGGER\"],\"action\":{\"name\":\"drilldown1\",\"config\":{\"useCurrentFilters\":true,\"useCurrentDateRange\":true},\"factoryId\":\"DASHBOARD_TO_DASHBOARD_DRILLDOWN\"}},{\"eventId\":\"b9c20d96-03ce-4dcc-8823-e3503311172e\",\"triggers\":[\"VALUE_CLICK_TRIGGER\"],\"action\":{\"name\":\"urlDrilldownToDiscover\",\"config\":{\"url\":{\"template\":\"{{kibanaUrl}}/app/discover#/?_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'561253e0-f731-11e8-8487-11b9dd924f96',key:{{event.key}},negate:!f,params:(query:{{event.value}}),type:phrase),query:(match_phrase:({{event.key}}:{{event.value}})))),index:'561253e0-f731-11e8-8487-11b9dd924f96',interval:auto,query:(language:kuery,query:''),sort:!())\"},\"openInNewTab\":false},\"factoryId\":\"URL_DRILLDOWN\"}}]}}},\"panelRefName\":\"panel_0\"}]", "optionsJSON" : "{\"useMargins\":true,\"hidePanelTitles\":false}", "version" : 1, "timeRestore" : true, @@ -1129,6 +1129,11 @@ }, "type" : "dashboard", "references" : [ + { + "name" : "drilldown:DASHBOARD_TO_DASHBOARD_DRILLDOWN:669a3521-1215-4228-9ced-77e2edf5ad17:dashboardId", + "type" : "dashboard", + "id" : "19906970-2e40-11e9-85cb-6965aae20f13" + }, { "name" : "panel_0", "type" : "map", @@ -1136,9 +1141,9 @@ } ], "migrationVersion" : { - "dashboard" : "7.3.0" + "dashboard" : "7.11.0" }, - "updated_at" : "2020-08-26T14:32:27.854Z" + "updated_at" : "2020-11-19T15:12:25.703Z" } } } diff --git a/x-pack/test/functional/es_archives/ml/module_auditbeat/data.json.gz b/x-pack/test/functional/es_archives/ml/module_auditbeat/data.json.gz new file mode 100644 index 00000000000000..6a9b6393977591 Binary files /dev/null and b/x-pack/test/functional/es_archives/ml/module_auditbeat/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/ml/module_auditbeat/mappings.json b/x-pack/test/functional/es_archives/ml/module_auditbeat/mappings.json new file mode 100644 index 00000000000000..1b7188b1410d83 --- /dev/null +++ b/x-pack/test/functional/es_archives/ml/module_auditbeat/mappings.json @@ -0,0 +1,4653 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "ft_module_auditbeat", + "mappings": { + "_meta": { + "beat": "auditbeat", + "version": "7.8.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "network.inner": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "network.inner.*" + } + }, + { + "observer.egress": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "observer.egress.*" + } + }, + { + "observer.ingress": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "observer.ingress.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "auditd": { + "properties": { + "data": { + "properties": { + "a0": { + "ignore_above": 1024, + "type": "keyword" + }, + "a1": { + "ignore_above": 1024, + "type": "keyword" + }, + "a2": { + "ignore_above": 1024, + "type": "keyword" + }, + "a3": { + "ignore_above": 1024, + "type": "keyword" + }, + "a[0-3]": { + "ignore_above": 1024, + "type": "keyword" + }, + "acct": { + "ignore_above": 1024, + "type": "keyword" + }, + "acl": { + "ignore_above": 1024, + "type": "keyword" + }, + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "added": { + "ignore_above": 1024, + "type": "keyword" + }, + "addr": { + "ignore_above": 1024, + "type": "keyword" + }, + "apparmor": { + "ignore_above": 1024, + "type": "keyword" + }, + "arch": { + "ignore_above": 1024, + "type": "keyword" + }, + "argc": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_backlog_limit": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_backlog_wait_time": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_failure": { + "ignore_above": 1024, + "type": "keyword" + }, + "banners": { + "ignore_above": 1024, + "type": "keyword" + }, + "bool": { + "ignore_above": 1024, + "type": "keyword" + }, + "bus": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fe": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fi": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fp": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fver": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "capability": { + "ignore_above": 1024, + "type": "keyword" + }, + "cgroup": { + "ignore_above": 1024, + "type": "keyword" + }, + "changed": { + "ignore_above": 1024, + "type": "keyword" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "cmd": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "compat": { + "ignore_above": 1024, + "type": "keyword" + }, + "daddr": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "default-context": { + "ignore_above": 1024, + "type": "keyword" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "dir": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "dmac": { + "ignore_above": 1024, + "type": "keyword" + }, + "dport": { + "ignore_above": 1024, + "type": "keyword" + }, + "enforcing": { + "ignore_above": 1024, + "type": "keyword" + }, + "entries": { + "ignore_above": 1024, + "type": "keyword" + }, + "exit": { + "ignore_above": 1024, + "type": "keyword" + }, + "fam": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "fd": { + "ignore_above": 1024, + "type": "keyword" + }, + "fe": { + "ignore_above": 1024, + "type": "keyword" + }, + "feature": { + "ignore_above": 1024, + "type": "keyword" + }, + "fi": { + "ignore_above": 1024, + "type": "keyword" + }, + "file": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "format": { + "ignore_above": 1024, + "type": "keyword" + }, + "fp": { + "ignore_above": 1024, + "type": "keyword" + }, + "fver": { + "ignore_above": 1024, + "type": "keyword" + }, + "grantors": { + "ignore_above": 1024, + "type": "keyword" + }, + "grp": { + "ignore_above": 1024, + "type": "keyword" + }, + "hook": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "icmp_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "igid": { + "ignore_above": 1024, + "type": "keyword" + }, + "img-ctx": { + "ignore_above": 1024, + "type": "keyword" + }, + "inif": { + "ignore_above": 1024, + "type": "keyword" + }, + "ino": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode_uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "invalid_context": { + "ignore_above": 1024, + "type": "keyword" + }, + "ioctlcmd": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "ignore_above": 1024, + "type": "keyword" + }, + "ipid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ipx-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "items": { + "ignore_above": 1024, + "type": "keyword" + }, + "iuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "ksize": { + "ignore_above": 1024, + "type": "keyword" + }, + "laddr": { + "ignore_above": 1024, + "type": "keyword" + }, + "len": { + "ignore_above": 1024, + "type": "keyword" + }, + "list": { + "ignore_above": 1024, + "type": "keyword" + }, + "lport": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "macproto": { + "ignore_above": 1024, + "type": "keyword" + }, + "maj": { + "ignore_above": 1024, + "type": "keyword" + }, + "major": { + "ignore_above": 1024, + "type": "keyword" + }, + "minor": { + "ignore_above": 1024, + "type": "keyword" + }, + "model": { + "ignore_above": 1024, + "type": "keyword" + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "nargs": { + "ignore_above": 1024, + "type": "keyword" + }, + "net": { + "ignore_above": 1024, + "type": "keyword" + }, + "new": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-chardev": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-disk": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-fs": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-level": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-log_passwd": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-mem": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-range": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-rng": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-role": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-vcpu": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_lock": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-fam": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-grp": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-pid": { + "ignore_above": 1024, + "type": "keyword" + }, + "oauid": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ocomm": { + "ignore_above": 1024, + "type": "keyword" + }, + "oflag": { + "ignore_above": 1024, + "type": "keyword" + }, + "old": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-auid": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-chardev": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-disk": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-fs": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-level": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-log_passwd": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-mem": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-range": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-rng": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-role": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-ses": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-vcpu": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_enforcing": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_lock": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pa": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_prom": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_val": { + "ignore_above": 1024, + "type": "keyword" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "opid": { + "ignore_above": 1024, + "type": "keyword" + }, + "oses": { + "ignore_above": 1024, + "type": "keyword" + }, + "outif": { + "ignore_above": 1024, + "type": "keyword" + }, + "pa": { + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "per": { + "ignore_above": 1024, + "type": "keyword" + }, + "perm": { + "ignore_above": 1024, + "type": "keyword" + }, + "perm_mask": { + "ignore_above": 1024, + "type": "keyword" + }, + "permissive": { + "ignore_above": 1024, + "type": "keyword" + }, + "pfs": { + "ignore_above": 1024, + "type": "keyword" + }, + "pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "printer": { + "ignore_above": 1024, + "type": "keyword" + }, + "prom": { + "ignore_above": 1024, + "type": "keyword" + }, + "proto": { + "ignore_above": 1024, + "type": "keyword" + }, + "qbytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "range": { + "ignore_above": 1024, + "type": "keyword" + }, + "reason": { + "ignore_above": 1024, + "type": "keyword" + }, + "removed": { + "ignore_above": 1024, + "type": "keyword" + }, + "res": { + "ignore_above": 1024, + "type": "keyword" + }, + "resrc": { + "ignore_above": 1024, + "type": "keyword" + }, + "rport": { + "ignore_above": 1024, + "type": "keyword" + }, + "sauid": { + "ignore_above": 1024, + "type": "keyword" + }, + "scontext": { + "ignore_above": 1024, + "type": "keyword" + }, + "selected-context": { + "ignore_above": 1024, + "type": "keyword" + }, + "seperm": { + "ignore_above": 1024, + "type": "keyword" + }, + "seperms": { + "ignore_above": 1024, + "type": "keyword" + }, + "seqno": { + "ignore_above": 1024, + "type": "keyword" + }, + "seresult": { + "ignore_above": 1024, + "type": "keyword" + }, + "ses": { + "ignore_above": 1024, + "type": "keyword" + }, + "seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "sig": { + "ignore_above": 1024, + "type": "keyword" + }, + "sigev_signo": { + "ignore_above": 1024, + "type": "keyword" + }, + "smac": { + "ignore_above": 1024, + "type": "keyword" + }, + "socket": { + "properties": { + "addr": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "ignore_above": 1024, + "type": "keyword" + }, + "saddr": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "spid": { + "ignore_above": 1024, + "type": "keyword" + }, + "sport": { + "ignore_above": 1024, + "type": "keyword" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "subj": { + "ignore_above": 1024, + "type": "keyword" + }, + "success": { + "ignore_above": 1024, + "type": "keyword" + }, + "syscall": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "tclass": { + "ignore_above": 1024, + "type": "keyword" + }, + "tcontext": { + "ignore_above": 1024, + "type": "keyword" + }, + "terminal": { + "ignore_above": 1024, + "type": "keyword" + }, + "tty": { + "ignore_above": 1024, + "type": "keyword" + }, + "unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "uri": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "val": { + "ignore_above": 1024, + "type": "keyword" + }, + "ver": { + "ignore_above": 1024, + "type": "keyword" + }, + "virt": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm-ctx": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm-pid": { + "ignore_above": 1024, + "type": "keyword" + }, + "watch": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "paths": { + "properties": { + "cap_fe": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fi": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fp": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fver": { + "ignore_above": 1024, + "type": "keyword" + }, + "dev": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "item": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "nametype": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_level": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_role": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_user": { + "ignore_above": 1024, + "type": "keyword" + }, + "objtype": { + "ignore_above": 1024, + "type": "keyword" + }, + "ogid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ouid": { + "ignore_above": 1024, + "type": "keyword" + }, + "rdev": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "result": { + "ignore_above": 1024, + "type": "keyword" + }, + "sequence": { + "type": "long" + }, + "session": { + "ignore_above": 1024, + "type": "keyword" + }, + "summary": { + "properties": { + "actor": { + "properties": { + "primary": { + "ignore_above": 1024, + "type": "keyword" + }, + "secondary": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "how": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "properties": { + "primary": { + "ignore_above": 1024, + "type": "keyword" + }, + "secondary": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "fields": { + "raw": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selinux": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "role": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "setgid": { + "type": "boolean" + }, + "setuid": { + "type": "boolean" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geoip": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "blake2b_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "xxh64": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "blake2b_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "xxh64": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "socket": { + "properties": { + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "system": { + "properties": { + "audit": { + "properties": { + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "boottime": { + "type": "date" + }, + "containerized": { + "type": "boolean" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timezone": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "offset": { + "properties": { + "sec": { + "type": "long" + } + } + } + } + }, + "uptime": { + "type": "long" + } + } + }, + "package": { + "properties": { + "arch": { + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "installtime": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "release": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "summary": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "dir": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "properties": { + "last_changed": { + "type": "date" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "shell": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "user_information": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "audit": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "effective": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "filesystem": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "name_map": { + "type": "object" + }, + "saved": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selinux": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "role": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "terminal": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": "5000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/ml/module_heartbeat/data.json.gz b/x-pack/test/functional/es_archives/ml/module_heartbeat/data.json.gz new file mode 100644 index 00000000000000..ba0b78aab3aa48 Binary files /dev/null and b/x-pack/test/functional/es_archives/ml/module_heartbeat/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/ml/module_heartbeat/mappings.json b/x-pack/test/functional/es_archives/ml/module_heartbeat/mappings.json new file mode 100644 index 00000000000000..e97531c6febf1f --- /dev/null +++ b/x-pack/test/functional/es_archives/ml/module_heartbeat/mappings.json @@ -0,0 +1,3390 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "ft_module_heartbeat", + "mappings": { + "_meta": { + "beat": "heartbeat", + "version": "8.0.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "network.inner": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "network.inner.*" + } + }, + { + "observer.egress": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "observer.egress.*" + } + }, + { + "observer.ingress": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "observer.ingress.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "redirects": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + } + } + }, + "rtt": { + "properties": { + "content": { + "properties": { + "us": { + "type": "long" + } + } + }, + "response_header": { + "properties": { + "us": { + "type": "long" + } + } + }, + "total": { + "properties": { + "us": { + "type": "long" + } + } + }, + "validate": { + "properties": { + "us": { + "type": "long" + } + } + }, + "validate_body": { + "properties": { + "us": { + "type": "long" + } + } + }, + "write_request": { + "properties": { + "us": { + "type": "long" + } + } + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "requests": { + "type": "long" + }, + "rtt": { + "properties": { + "us": { + "type": "long" + } + } + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "monitor": { + "properties": { + "check_group": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "properties": { + "us": { + "type": "long" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "timespan": { + "type": "date_range" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolve": { + "properties": { + "ip": { + "type": "ip" + }, + "rtt": { + "properties": { + "us": { + "type": "long" + } + } + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "socks5": { + "properties": { + "rtt": { + "properties": { + "connect": { + "properties": { + "us": { + "type": "long" + } + } + } + } + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "summary": { + "properties": { + "down": { + "type": "long" + }, + "up": { + "type": "long" + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "tcp": { + "properties": { + "rtt": { + "properties": { + "connect": { + "properties": { + "us": { + "type": "long" + } + } + }, + "validate": { + "properties": { + "us": { + "type": "long" + } + } + } + } + } + } + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "certificate_not_valid_after": { + "type": "date" + }, + "certificate_not_valid_before": { + "type": "date" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "rtt": { + "properties": { + "handshake": { + "properties": { + "us": { + "type": "long" + } + } + } + } + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/README.md b/x-pack/test/functional/es_archives/rule_exceptions/README.md new file mode 100644 index 00000000000000..1fbf4962d55fea --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/README.md @@ -0,0 +1,11 @@ +Within this folder is input test data for tests such as: + +```ts +security_and_spaces/tests/rule_exceptions.ts +``` + +where these are small ECS compliant input indexes that try to express tests that exercise different parts of +the detection engine around creating and validating that the exceptions part of the detection engine functions. +Compliant meaning that these might contain extra fields but should not clash with ECS. Nothing stopping anyone +from being ECS strict and not having additional extra fields but the extra fields and mappings are to just try +and keep these tests simple and small. diff --git a/x-pack/test/functional/es_archives/rule_exceptions/date/data.json b/x-pack/test/functional/es_archives/rule_exceptions/date/data.json new file mode 100644 index 00000000000000..dd1609070a19d6 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/date/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "date", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "date": "2020-10-01T05:08:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "date", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "date": "2020-10-02T05:08:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "date", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "date": "2020-10-03T05:08:53.000Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "date", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "date": "2020-10-04T05:08:53.000Z" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/date/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/date/mappings.json new file mode 100644 index 00000000000000..28c0158cdc8523 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/date/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "date", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "date": { "type": "date" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/double/data.json b/x-pack/test/functional/es_archives/rule_exceptions/double/data.json new file mode 100644 index 00000000000000..1f7a5969f5872a --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/double/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "double", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "double": 1.0 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "double", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "double": 1.1 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "double", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "double": 1.2 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "double", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "double": 1.3 + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/double/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/double/mappings.json new file mode 100644 index 00000000000000..bd69ae19ed1480 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/double/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "double", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "double": { "type": "double" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/data.json b/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/data.json new file mode 100644 index 00000000000000..2bdd685fae4c9b --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "double_as_string", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "double": "1.0" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "double_as_string", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "double": "1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "double_as_string", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "double": "1.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "double_as_string", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "double": "1.3" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/mappings.json new file mode 100644 index 00000000000000..a3b3fc52325a5c --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/double_as_string/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "double_as_string", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "double": { "type": "double" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/float/data.json b/x-pack/test/functional/es_archives/rule_exceptions/float/data.json new file mode 100644 index 00000000000000..888be5ff20a32f --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/float/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "float", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "float": 1.0 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "float", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "float": 1.1 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "float", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "float": 1.2 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "float", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "float": 1.3 + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/float/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/float/mappings.json new file mode 100644 index 00000000000000..b0a7b1a7fc60c8 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/float/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "float", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "float": { "type": "float" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/data.json b/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/data.json new file mode 100644 index 00000000000000..4d8575d3ccb9c3 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "float_as_string", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "float": "1.0" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "float_as_string", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "float": "1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "float_as_string", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "float": "1.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "float_as_string", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "float": "1.3" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/mappings.json new file mode 100644 index 00000000000000..7e66ace5eb5c6b --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/float_as_string/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "float_as_string", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "float": { "type": "float" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/integer/data.json b/x-pack/test/functional/es_archives/rule_exceptions/integer/data.json new file mode 100644 index 00000000000000..5e2f1295397e6b --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/integer/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "integer", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "integer": 1 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "integer", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "integer": 2 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "integer", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "integer": 3 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "integer", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "integer": 4 + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/integer/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/integer/mappings.json new file mode 100644 index 00000000000000..a05f3ec4e31866 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/integer/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "integer", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "integer": { "type": "integer" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/data.json b/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/data.json new file mode 100644 index 00000000000000..5d0ac56e27d001 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "integer_as_string", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "integer": "1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "integer_as_string", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "integer": "2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "integer_as_string", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "integer": "3" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "integer_as_string", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "integer": "4" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/mappings.json new file mode 100644 index 00000000000000..e98d0d89214dd0 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/integer_as_string/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "integer_as_string", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "integer": { "type": "integer" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/ip/data.json b/x-pack/test/functional/es_archives/rule_exceptions/ip/data.json new file mode 100644 index 00000000000000..5dde1cba8f8849 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/ip/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "ip", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "ip": "127.0.0.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "ip", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "ip": "127.0.0.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "ip", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "ip": "127.0.0.3" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "ip", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "ip": "127.0.0.4" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/ip/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/ip/mappings.json new file mode 100644 index 00000000000000..ceb58bc9335079 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/ip/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "ip", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "ip": { "type": "ip" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/keyword/data.json b/x-pack/test/functional/es_archives/rule_exceptions/keyword/data.json new file mode 100644 index 00000000000000..09c54843f32c98 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/keyword/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "keyword": "word one" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "keyword": "word two" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "keyword": "word three" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "keyword", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "keyword": "word four" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/keyword/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/keyword/mappings.json new file mode 100644 index 00000000000000..bc8becbe07f451 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/keyword/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "keyword", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "keyword": { "type": "keyword" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/long/data.json b/x-pack/test/functional/es_archives/rule_exceptions/long/data.json new file mode 100644 index 00000000000000..807314bd28173b --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/long/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "long", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "long": 1 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "long", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "long": 2 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "long", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "long": 3 + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "long", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "long": 4 + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/long/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/long/mappings.json new file mode 100644 index 00000000000000..75b156805af785 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/long/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "long", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "long": { "type": "long" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/data.json b/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/data.json new file mode 100644 index 00000000000000..3604026d2cdb0d --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "long_as_string", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "long": "1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "long_as_string", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "long": "2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "long_as_string", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "long": "3" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "long_as_string", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "long": "4" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/mappings.json new file mode 100644 index 00000000000000..8fe9af08127d1e --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/long_as_string/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "long_as_string", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "long": { "type": "long" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text/data.json b/x-pack/test/functional/es_archives/rule_exceptions/text/data.json new file mode 100644 index 00000000000000..8d3da48224cc3b --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "text", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "text": "word one" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "text", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "text": "word two" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "text", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "text": "word three" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "text", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "text": "word four" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/text/mappings.json new file mode 100644 index 00000000000000..5d3304fc202d54 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "text", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "text": { "type": "text" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/data.json b/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/data.json new file mode 100644 index 00000000000000..a0caf9d9eb2d3f --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "text_no_spaces", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "text": "one" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "text_no_spaces", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "text": "two" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "text_no_spaces", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "text": "three" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "text_no_spaces", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "text": "four" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/mappings.json new file mode 100644 index 00000000000000..b981af79371241 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text_no_spaces/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "text_no_spaces", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "text": { "type": "text" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/wildcard/data.json b/x-pack/test/functional/es_archives/rule_exceptions/wildcard/data.json new file mode 100644 index 00000000000000..40dd24f83c0d24 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/wildcard/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "wildcard", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "wildcard": "word one" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "wildcard", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "wildcard": "word two" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "wildcard": "wildcard", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "wildcard": "word three" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "wildcard", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "wildcard": "word four" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/wildcard/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/wildcard/mappings.json new file mode 100644 index 00000000000000..1b6a697ecbb8fc --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/wildcard/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "wildcard", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "wildcard": { "type": "wildcard" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/signals/README.md b/x-pack/test/functional/es_archives/signals/README.md new file mode 100644 index 00000000000000..4b147a414f8b3d --- /dev/null +++ b/x-pack/test/functional/es_archives/signals/README.md @@ -0,0 +1,22 @@ +Within this folder is input test data for tests such as: + +```ts +security_and_spaces/tests/generating_signals.ts +``` + +where these are small ECS compliant input indexes that try to express tests that exercise different parts of +the detection engine signals. Compliant meaning that these might contain extra fields but should not clash with ECS. +Nothing stopping anyone from being ECS strict and not having additional extra fields but the extra fields and mappings +are to just try and keep these tests simple and small. Examples are: + + +This is an ECS document that has a numeric name clash with a signal structure +``` +numeric_name_clash +``` + +This is an ECS document that has an object name clash with a signal structure +``` +object_clash +``` + diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 2d9ee00234bb67..ef80ab475cbd67 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -5,7 +5,7 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; -import { Role } from '../../../plugins/security/common/model'; +import { AuthenticatedUser, Role } from '../../../plugins/security/common/model'; export function SecurityPageProvider({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); @@ -17,6 +17,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const esArchiver = getService('esArchiver'); const userMenu = getService('userMenu'); const comboBox = getService('comboBox'); + const supertest = getService('supertestWithoutAuth'); const PageObjects = getPageObjects(['common', 'header', 'error']); interface LoginOptions { @@ -41,10 +42,14 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider }); } + async function isLoginFormVisible() { + return await testSubjects.exists('loginForm'); + } + async function waitForLoginForm() { log.debug('Waiting for Login Form to appear.'); await retry.waitForWithTimeout('login form', config.get('timeouts.waitFor') * 5, async () => { - return await testSubjects.exists('loginForm'); + return await isLoginFormVisible(); }); } @@ -107,7 +112,9 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const loginPage = Object.freeze({ async login(username?: string, password?: string, options: LoginOptions = {}) { - await PageObjects.common.navigateToApp('login'); + if (!(await isLoginFormVisible())) { + await PageObjects.common.navigateToApp('login'); + } // ensure welcome screen won't be shown. This is relevant for environments which don't allow // to use the yml setting, e.g. cloud @@ -218,6 +225,21 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider await waitForLoginPage(); } + async getCurrentUser() { + const sidCookie = await browser.getCookie('sid'); + if (!sidCookie?.value) { + log.debug('User is not authenticated yet.'); + return null; + } + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', `sid=${sidCookie.value}`) + .expect(200); + return user as AuthenticatedUser; + } + async forceLogout() { log.debug('SecurityPage.forceLogout'); if (await find.existsByDisplayedByCssSelector('.login-form', 100)) { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 6d8eade25d7e6d..1aa6216236827d 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -6,6 +6,7 @@ import { services as kibanaFunctionalServices } from '../../../../test/functional/services'; import { services as kibanaApiIntegrationServices } from '../../../../test/api_integration/services'; +import { services as kibanaXPackApiIntegrationServices } from '../../api_integration/services'; import { services as commonServices } from '../../common/services'; import { @@ -64,6 +65,7 @@ export const services = { ...commonServices, supertest: kibanaApiIntegrationServices.supertest, + supertestWithoutAuth: kibanaXPackApiIntegrationServices.supertestWithoutAuth, esSupertest: kibanaApiIntegrationServices.esSupertest, monitoringNoData: MonitoringNoDataProvider, monitoringClusterList: MonitoringClusterListProvider, diff --git a/x-pack/test/lists_api_integration/security_and_spaces/tests/import_list_items.ts b/x-pack/test/lists_api_integration/security_and_spaces/tests/import_list_items.ts index 7b7a6173fb4081..ae9814e603b743 100644 --- a/x-pack/test/lists_api_integration/security_and_spaces/tests/import_list_items.ts +++ b/x-pack/test/lists_api_integration/security_and_spaces/tests/import_list_items.ts @@ -94,7 +94,7 @@ export default ({ getService }: FtrProviderContext): void => { .get(`${LIST_ITEM_URL}?list_id=list_items.txt&value=127.0.0.1`) .send(); return status !== 404; - }); + }, `${LIST_ITEM_URL}?list_id=list_items.txt&value=127.0.0.1`); const { body } = await supertest .get(`${LIST_ITEM_URL}?list_id=list_items.txt&value=127.0.0.1`) .send() diff --git a/x-pack/test/lists_api_integration/utils.ts b/x-pack/test/lists_api_integration/utils.ts index 5870239b73ed11..224048e868d7f0 100644 --- a/x-pack/test/lists_api_integration/utils.ts +++ b/x-pack/test/lists_api_integration/utils.ts @@ -8,13 +8,15 @@ import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; import { Client } from '@elastic/elasticsearch'; +import { getImportListItemAsBuffer } from '../../plugins/lists/common/schemas/request/import_list_item_schema.mock'; import { ListItemSchema, ExceptionListSchema, ExceptionListItemSchema, + Type, } from '../../plugins/lists/common/schemas'; import { ListSchema } from '../../plugins/lists/common'; -import { LIST_INDEX } from '../../plugins/lists/common/constants'; +import { LIST_INDEX, LIST_ITEM_URL } from '../../plugins/lists/common/constants'; import { countDownES, countDownTest } from '../detection_engine_api_integration/utils'; /** @@ -109,6 +111,7 @@ export const removeExceptionListServerGeneratedProperties = ( // Similar to ReactJs's waitFor from here: https://testing-library.com/docs/dom-testing-library/api-async#waitfor export const waitFor = async ( functionToTest: () => Promise<boolean>, + functionName: string, maxTimeout: number = 5000, timeoutWait: number = 10 ) => { @@ -127,7 +130,7 @@ export const waitFor = async ( if (found) { resolve(); } else { - reject(new Error('timed out waiting for function condition to be true')); + reject(new Error(`timed out waiting for function ${functionName} condition to be true`)); } }); }; @@ -164,3 +167,134 @@ export const deleteAllExceptions = async (es: Client): Promise<void> => { }); }, 'deleteAllExceptions'); }; + +/** + * Convenience function for quickly importing a given type and contents and then + * waiting to ensure they're there before continuing + * @param supertest The super test agent + * @param type The type to import as + * @param contents The contents of the import + * @param fileName filename to import as + */ +export const importFile = async ( + supertest: SuperTest<supertestAsPromised.Test>, + type: Type, + contents: string[], + fileName: string +): Promise<void> => { + await supertest + .post(`${LIST_ITEM_URL}/_import?type=${type}`) + .set('kbn-xsrf', 'true') + .attach('file', getImportListItemAsBuffer(contents), fileName) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + + // although we have pushed the list and its items, it is async so we + // have to wait for the contents before continuing + await waitForListItems(supertest, contents, fileName); +}; + +/** + * Convenience function for quickly importing a given type and contents and then + * waiting to ensure they're there before continuing. This specifically checks tokens + * from text file + * @param supertest The super test agent + * @param type The type to import as + * @param contents The contents of the import + * @param fileName filename to import as + */ +export const importTextFile = async ( + supertest: SuperTest<supertestAsPromised.Test>, + type: Type, + contents: string[], + fileName: string +): Promise<void> => { + await supertest + .post(`${LIST_ITEM_URL}/_import?type=${type}`) + .set('kbn-xsrf', 'true') + .attach('file', getImportListItemAsBuffer(contents), fileName) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + + // although we have pushed the list and its items, it is async so we + // have to wait for the contents before continuing + await waitForTextListItems(supertest, contents, fileName); +}; + +/** + * Convenience function for waiting for a particular file uploaded + * and a particular item value to be available before continuing. + * @param supertest The super test agent + * @param fileName The filename imported + * @param itemValue The item value to wait for + */ +export const waitForListItem = async ( + supertest: SuperTest<supertestAsPromised.Test>, + itemValue: string, + fileName: string +): Promise<void> => { + await waitFor(async () => { + const { status } = await supertest + .get(`${LIST_ITEM_URL}?list_id=${fileName}&value=${itemValue}`) + .send(); + + return status === 200; + }, `waitForListItem fileName: "${fileName}" itemValue: "${itemValue}"`); +}; + +/** + * Convenience function for waiting for a particular file uploaded + * and particular item values to be available before continuing. + * @param supertest The super test agent + * @param fileName The filename imported + * @param itemValue The item value to wait for + */ +export const waitForListItems = async ( + supertest: SuperTest<supertestAsPromised.Test>, + itemValues: string[], + fileName: string +): Promise<void> => { + await Promise.all(itemValues.map((item) => waitForListItem(supertest, item, fileName))); +}; + +/** + * Convenience function for waiting for a particular file uploaded + * and a particular item value to be available before continuing. + * @param supertest The super test agent + * @param fileName The filename imported + * @param itemValue The item value to wait for + */ +export const waitForTextListItem = async ( + supertest: SuperTest<supertestAsPromised.Test>, + itemValue: string, + fileName: string +): Promise<void> => { + const tokens = itemValue.split(' '); + await waitFor(async () => { + const promises = await Promise.all( + tokens.map(async (token) => { + const { status } = await supertest + .get(`${LIST_ITEM_URL}?list_id=${fileName}&value=${token}`) + .send(); + return status === 200; + }) + ); + return promises.every((one) => one); + }, `waitForTextListItem fileName: "${fileName}" itemValue: "${itemValue}"`); +}; + +/** + * Convenience function for waiting for a particular file uploaded + * and particular item values to be available before continuing. This works + * specifically with text types and does tokenization to ensure all words are uploaded + * @param supertest The super test agent + * @param fileName The filename imported + * @param itemValue The item value to wait for + */ +export const waitForTextListItems = async ( + supertest: SuperTest<supertestAsPromised.Test>, + itemValues: string[], + fileName: string +): Promise<void> => { + await Promise.all(itemValues.map((item) => waitForTextListItem(supertest, item, fileName))); +}; diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts index 11af83631502bd..95f3770443ccbb 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/init_routes.ts @@ -140,33 +140,6 @@ export const getProviderActionsRoute = ( ); }; -export const getLoggerRoute = ( - router: IRouter, - eventLogService: IEventLogService, - logger: Logger -) => { - router.get( - { - path: `/api/log_event_fixture/getEventLogger/{event}`, - validate: { - params: (value: any, { ok }: RouteValidationResultFactory) => ok(value), - }, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest<any, any, any, any>, - res: KibanaResponseFactory - ): Promise<IKibanaResponse<any>> { - const { event } = req.params as { event: string }; - logger.info(`test get event logger for event: ${event}`); - - return res.ok({ - body: { eventLogger: eventLogService.getLogger({ event: { provider: event } }) }, - }); - } - ); -}; - export const isIndexingEntriesRoute = ( router: IRouter, eventLogService: IEventLogService, diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts index 4fb0511db21942..94e5e6faa2b431 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts @@ -11,7 +11,6 @@ import { registerProviderActionsRoute, isProviderActionRegisteredRoute, getProviderActionsRoute, - getLoggerRoute, isIndexingEntriesRoute, isEventLogServiceLoggingEntriesRoute, isEventLogServiceEnabledRoute, @@ -56,7 +55,6 @@ export class EventLogFixturePlugin registerProviderActionsRoute(router, eventLog, this.logger); isProviderActionRegisteredRoute(router, eventLog, this.logger); getProviderActionsRoute(router, eventLog, this.logger); - getLoggerRoute(router, eventLog, this.logger); isIndexingEntriesRoute(router, eventLog, this.logger); isEventLogServiceLoggingEntriesRoute(router, eventLog, this.logger); isEventLogServiceEnabledRoute(router, eventLog, this.logger); diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index 5f827dd3eded64..c246e2945a6dd9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -79,18 +79,6 @@ export default function ({ getService }: FtrProviderContext) { expect(providerActions.body.actions).to.be.eql(['action1', 'action2']); }); - it('should allow to get event logger event log service', async () => { - const initResult = await isProviderActionRegistered('provider2', 'action1'); - - if (!initResult.body.isProviderActionRegistered) { - await registerProviderActions('provider2', ['action1', 'action2']); - } - const eventLogger = await getEventLogger('provider2'); - expect(eventLogger.body.eventLogger.initialProperties).to.be.eql({ - event: { provider: 'provider2' }, - }); - }); - it('should allow write an event to index document if indexing entries is enabled', async () => { const initResult = await isProviderActionRegistered('provider4', 'action1'); @@ -138,14 +126,6 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); } - async function getEventLogger(event: string) { - log.debug(`isProviderActionRegistered for event ${event}`); - return await supertest - .get(`/api/log_event_fixture/getEventLogger/${event}`) - .set('kbn-xsrf', 'foo') - .expect(200); - } - async function isIndexingEntries() { log.debug(`isIndexingEntries`); return await supertest diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts index d78513ca062060..6bacd5a625a156 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/index.ts @@ -14,5 +14,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./usage_collection')); }); } diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.ts new file mode 100644 index 00000000000000..8804c2cd2ad598 --- /dev/null +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/usage_collection.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const usageAPI = getService('usageAPI'); + + describe('saved_object_tagging usage collector data', () => { + beforeEach(async () => { + await esArchiver.load('usage_collection'); + }); + + afterEach(async () => { + await esArchiver.unload('usage_collection'); + }); + + /* + * Dataset description: + * + * 5 tags: tag-1 tag-2 tag-3 tag-4 ununsed-tag + * 3 dashboard: + * - dash-1: ref to tag-1 + tag-2 + * - dash-2: ref to tag-2 + tag 4 + * - dash-3: no ref to any tag + * 3 visualization: + * - vis-1: ref to tag-1 + * - vis-2: ref to tag-1 + tag-3 + * - vis-3: ref to tag-3 + */ + it('collects the expected data', async () => { + const telemetryStats = (await usageAPI.getTelemetryStats({ + unencrypted: true, + timestamp: Date.now(), + })) as any; + + const taggingStats = telemetryStats[0].stack_stats.kibana.plugins.saved_objects_tagging; + expect(taggingStats).to.eql({ + usedTags: 4, + taggedObjects: 5, + types: { + dashboard: { + taggedObjects: 2, + usedTags: 3, + }, + visualization: { + taggedObjects: 3, + usedTags: 2, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json new file mode 100644 index 00000000000000..a9535ae9e40b21 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json @@ -0,0 +1,313 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#FFFFFF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#000000" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-4", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-4", + "description": "Last", + "color": "#000000" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:unused-tag", + "index": ".kibana", + "source": { + "tag": { + "name": "unused-tag", + "description": "This tag is unused and should only appear in totalTags", + "color": "#123456" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-1-and-tag-3", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-1 and tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-1", + "name": "tag-1" + }, + { + "type": "tag", + "id": "tag-3", + "name": "tag-3" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:ref-to-tag-3", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Vis with ref to tag-2", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [ + { + "type": "tag", + "id": "tag-3", + "name": "tag-3" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-1-and-tag-2", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 1 (tag-2)", + "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": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-2-and-tag-4", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-2 and tag-4)", + "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": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + }, + { + "id": "tag-4", + "name": "tag-4-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:no-tag-reference", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-2 and tag-4)", + "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": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/mappings.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/mappings.json new file mode 100644 index 00000000000000..9cf628bef47675 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/mappings.json @@ -0,0 +1,266 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "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" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/security_api_integration/anonymous.config.ts b/x-pack/test/security_api_integration/anonymous.config.ts new file mode 100644 index 00000000000000..1742bd09c92f5a --- /dev/null +++ b/x-pack/test/security_api_integration/anonymous.config.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [require.resolve('./tests/anonymous')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services: { + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), + }, + junit: { + reportName: 'X-Pack Security API Integration Tests (Anonymous with Username and Password)', + }, + + esTestCluster: { ...xPackAPITestsConfig.get('esTestCluster') }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--xpack.security.authc.selector.enabled=false`, + `--xpack.security.authc.providers=${JSON.stringify({ + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, + basic: { basic1: { order: 1 } }, + })}`, + ], + }, + }; +} diff --git a/x-pack/test/security_api_integration/login_selector.config.ts b/x-pack/test/security_api_integration/login_selector.config.ts index 9688d42cb43617..97c7b4334c3b7b 100644 --- a/x-pack/test/security_api_integration/login_selector.config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { readFileSync } from 'fs'; import { resolve } from 'path'; import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; @@ -35,6 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kibana: { ...xPackAPITestsConfig.get('servers.kibana'), protocol: 'https', + certificateAuthorities: [readFileSync(CA_CERT_PATH)], }, }; @@ -43,9 +45,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { servers, security: { disableTestUser: true }, services: { - randomness: kibanaAPITestsConfig.get('services.randomness'), - legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), - supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), }, junit: { reportName: 'X-Pack Security API Integration Tests (Login Selector)', @@ -127,6 +128,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { useRelayStateDeepLink: true, }, }, + anonymous: { + anonymous1: { + order: 6, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, })}`, ], }, diff --git a/x-pack/test/security_api_integration/tests/anonymous/index.ts b/x-pack/test/security_api_integration/tests/anonymous/index.ts new file mode 100644 index 00000000000000..3819d26ae5efa5 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Anonymous access', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./login')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/anonymous/login.ts b/x-pack/test/security_api_integration/tests/anonymous/login.ts new file mode 100644 index 00000000000000..e7c876f54ee5a0 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/login.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import request, { Cookie } from 'request'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + const security = getService('security'); + + function checkCookieIsSet(cookie: Cookie) { + expect(cookie.value).to.not.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(null); + } + + function checkCookieIsCleared(cookie: Cookie) { + expect(cookie.value).to.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(0); + } + + describe('Anonymous authentication', () => { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + }); + + it('should reject API requests if client is not authenticated', async () => { + await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); + }); + + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const cookie = request.cookie(cookies[0])!; + checkCookieIsSet(cookie); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', cookie.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic1' }); + expect(user.authentication_type).to.eql('realm'); + // Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud + }); + + describe('login', () => { + it('should properly set cookie and authenticate user', async () => { + const response = await supertest.get('/security/account').expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(user.username).to.eql('anonymous_user'); + expect(user.authentication_provider).to.eql({ type: 'anonymous', name: 'anonymous1' }); + expect(user.authentication_type).to.eql('realm'); + // Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud + }); + + it('should fail if `Authorization` header is present, but not valid', async () => { + const response = await supertest + .get('/security/account') + .set('Authorization', 'Basic wow') + .expect(401); + expect(response.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('API access with active session', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest.get('/security/account').expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('should not extend cookie for system AND non-system API calls', async () => { + const apiResponseOne = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.be(undefined); + + const systemAPIResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('kbn-system-request', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Basic a3JiNTprcmI1') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + it('should redirect to `logged_out` page after successful logout', async () => { + // First authenticate user to retrieve session cookie. + const response = await supertest.get('/security/account').expect(200); + let cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // And then log user out. + const logoutResponse = await supertest + .get('/api/security/logout') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(logoutResponse.headers.location).to.be('/security/logged_out'); + + // Old cookie should be invalidated and not allow API access. + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + // If Kibana detects cookie with invalid token it tries to clear it. + cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest.get('/api/security/logout').expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index cf141972b044a1..edcc1b5744fe37 100644 --- a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -23,6 +23,7 @@ export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); const supertest = getService('supertestWithoutAuth'); const config = getService('config'); + const security = getService('security'); const kibanaServerConfig = config.get('servers.kibana'); const validUsername = kibanaServerConfig.username; @@ -748,5 +749,68 @@ export default function ({ getService }: FtrProviderContext) { ); }); }); + + describe('Anonymous', () => { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + }); + + it('should be able to log in from Login Selector', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'anonymous', + providerName: 'anonymous1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'anonymous_user', + { type: 'anonymous', name: 'anonymous1' }, + { name: 'native1', type: 'native' }, + 'realm' + ); + }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'anonymous', + providerName: 'anonymous1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'anonymous_user', + { type: 'anonymous', name: 'anonymous1' }, + { name: 'native1', type: 'native' }, + 'realm' + ); + }); + }); }); } diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index 9fc4c54ba13444..2ee47491c5ff38 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -42,7 +42,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { from: 'snapshot', serverArgs: [ 'xpack.security.authc.token.enabled=true', - 'xpack.security.authc.realms.saml.saml1.order=0', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.saml.saml1.order=1', `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, @@ -60,15 +61,29 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', `--xpack.security.loginHelp="Some-login-help."`, - '--xpack.security.authc.providers.basic.basic1.order=0', - '--xpack.security.authc.providers.saml.saml1.order=1', - '--xpack.security.authc.providers.saml.saml1.realm=saml1', - '--xpack.security.authc.providers.saml.saml1.description="Log-in-with-SAML"', - '--xpack.security.authc.providers.saml.saml1.icon=logoKibana', - '--xpack.security.authc.providers.saml.unknown_saml.order=2', - '--xpack.security.authc.providers.saml.unknown_saml.realm=unknown_realm', - '--xpack.security.authc.providers.saml.unknown_saml.description="Do-not-log-in-with-THIS-SAML"', - '--xpack.security.authc.providers.saml.unknown_saml.icon=logoAWS', + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { basic1: { order: 0 } }, + saml: { + saml1: { + order: 1, + realm: 'saml1', + description: 'Log-in-with-SAML', + icon: 'logoKibana', + }, + unknown_saml: { + order: 2, + realm: 'unknown_realm', + description: 'Do-not-log-in-with-THIS-SAML', + icon: 'logoAWS', + }, + }, + anonymous: { + anonymous1: { + order: 3, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, + })}`, ], }, uiSettings: { diff --git a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts new file mode 100644 index 00000000000000..8c208625590927 --- /dev/null +++ b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const security = getService('security'); + const PageObjects = getPageObjects(['security', 'common']); + + describe('Authentication provider hint', function () { + this.tags('includeFirefox'); + + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/saml1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) + .expect(200); + + await security.user.create('anonymous_user', { + password: 'changeme', + roles: ['superuser'], + full_name: 'Guest', + }); + + await esArchiver.load('../../functional/es_archives/empty_kibana'); + await PageObjects.security.forceLogout(); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + await esArchiver.unload('../../functional/es_archives/empty_kibana'); + }); + + beforeEach(async () => { + await browser.get(`${PageObjects.common.getHostPort()}/login`); + await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); + }); + + afterEach(async () => { + await PageObjects.security.forceLogout(); + }); + + it('automatically activates Login Form preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=basic1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + await PageObjects.common.waitUntilUrlIncludes('next='); + + // Login form should be automatically activated by the auth provider hint. + await PageObjects.security.loginSelector.verifyLoginFormIsVisible(); + await PageObjects.security.loginPage.login(undefined, undefined, { expectSuccess: true }); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'basic', + name: 'basic1', + }); + }); + + it('automatically login with SSO preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=saml1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + + await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users'); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'saml', + name: 'saml1', + }); + }); + + it('can login anonymously preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=anonymous1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + + await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users'); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'anonymous', + name: 'anonymous1', + }); + }); + }); +} diff --git a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index 153387c52e5c3c..a08fae4cdb0a13 100644 --- a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const security = getService('security'); const PageObjects = getPageObjects(['security', 'common']); describe('Basic functionality', function () { @@ -71,6 +72,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(currentURL.pathname).to.eql('/app/management/security/users'); }); + it('can login anonymously preserving original URL', async () => { + await PageObjects.common.navigateToUrl('management', 'security/users', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); + await PageObjects.common.waitUntilUrlIncludes('next='); + + await security.user.create('anonymous_user', { + password: 'changeme', + roles: ['superuser'], + full_name: 'Guest', + }); + await PageObjects.security.loginSelector.login('anonymous', 'anonymous1'); + await security.user.delete('anonymous_user'); + + // We need to make sure that both path and hash are respected. + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + }); + it('should show toast with error if SSO fails', async () => { await PageObjects.security.loginSelector.selectLoginMethod('saml', 'unknown_saml'); @@ -80,6 +102,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); + it('should show toast with error if anonymous login fails', async () => { + await PageObjects.security.loginSelector.selectLoginMethod('anonymous', 'anonymous1'); + + const toastTitle = await PageObjects.common.closeToast(); + expect(toastTitle).to.eql('Could not perform login.'); + + await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); + }); + it('can go to Login Form and return back to Selector', async () => { await PageObjects.security.loginSelector.selectLoginMethod('basic', 'basic1'); await PageObjects.security.loginSelector.verifyLoginFormIsVisible(); diff --git a/x-pack/test/security_functional/tests/login_selector/index.ts b/x-pack/test/security_functional/tests/login_selector/index.ts index 0d1060fbf1f513..ee25e365d495d3 100644 --- a/x-pack/test/security_functional/tests/login_selector/index.ts +++ b/x-pack/test/security_functional/tests/login_selector/index.ts @@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup4'); loadTestFile(require.resolve('./basic_functionality')); + loadTestFile(require.resolve('./auth_provider_hint')); }); } diff --git a/x-pack/plugins/apm/typings/cytoscape_dagre.d.ts b/x-pack/typings/cytoscape_dagre.d.ts similarity index 100% rename from x-pack/plugins/apm/typings/cytoscape_dagre.d.ts rename to x-pack/typings/cytoscape_dagre.d.ts diff --git a/x-pack/plugins/apm/typings/react_vis.d.ts b/x-pack/typings/react_vis.d.ts similarity index 100% rename from x-pack/plugins/apm/typings/react_vis.d.ts rename to x-pack/typings/react_vis.d.ts diff --git a/yarn.lock b/yarn.lock index 3495f9bc2bf53a..0cf4328669472b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2657,6 +2657,10 @@ version "0.0.0" uid "" +"@kbn/legacy-logging@link:packages/kbn-legacy-logging": + version "0.0.0" + uid "" + "@kbn/logging@link:packages/kbn-logging": version "0.0.0" uid "" @@ -4485,11 +4489,6 @@ dependencies: "@types/webpack" "*" -"@types/console-stamp@^0.2.32": - version "0.2.32" - resolved "https://registry.yarnpkg.com/@types/console-stamp/-/console-stamp-0.2.32.tgz#9cb9dce41b6203a28486365300a8a1cf99e5801f" - integrity sha512-Ih8HUSWSNtmHf5DgLv+BZGKaNGZKOaFjkxb/nHOBfc2wLrWY5wFQq6rjLu+LxCD/Mc+8GoKhby364Bu0Be25tQ== - "@types/cookiejar@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.0.tgz#4b7daf2c51696cfc70b942c11690528229d1a1ce" @@ -10034,15 +10033,6 @@ console-log-level@^1.4.1: resolved "https://registry.yarnpkg.com/console-log-level/-/console-log-level-1.4.1.tgz#9c5a6bb9ef1ef65b05aba83028b0ff894cdf630a" integrity sha512-VZzbIORbP+PPcN/gg3DXClTLPLg5Slwd5fL2MIc+o1qZ4BXBvWyc6QxPk6T/Mkr6IVjRpoAGf32XxP3ZWMVRcQ== -console-stamp@^0.2.9: - version "0.2.9" - resolved "https://registry.yarnpkg.com/console-stamp/-/console-stamp-0.2.9.tgz#9c0cd206d1fd60dec4e263ddeebde2469209c99f" - integrity sha512-jtgd1Fx3Im+pWN54mF269ptunkzF5Lpct2LBTbtyNoK2A4XjcxLM+TQW+e+XE/bLwLQNGRqPqlxm9JMixFntRA== - dependencies: - chalk "^1.1.1" - dateformat "^1.0.11" - merge "^1.2.0" - constant-case@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-2.0.0.tgz#4175764d389d3fa9c8ecd29186ed6005243b6a46" @@ -11101,14 +11091,6 @@ date-now@^0.1.4: resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= -dateformat@^1.0.11: - version "1.0.12" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9" - integrity sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk= - dependencies: - get-stdin "^4.0.1" - meow "^3.3.0" - dateformat@^3.0.2, dateformat@~3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" @@ -18805,16 +18787,28 @@ livereload-js@^2.3.0: resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.4.0.tgz#447c31cf1ea9ab52fc20db615c5ddf678f78009c" integrity sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw== -lmdb-store@^0.6.10: - version "0.6.10" - resolved "https://registry.yarnpkg.com/lmdb-store/-/lmdb-store-0.6.10.tgz#db8efde6e052aabd17ebc63c8a913e1f31694129" - integrity sha512-ZLvp3qbBQ5VlBmaWa4EUAPyYEZ8qdUHsW69HmxkDi84pFQ37WMxYhFaF/7PQkdtxS/vyiKkZigd9TFgHjek1Nw== +lmdb-store-0.9@0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/lmdb-store-0.9/-/lmdb-store-0.9-0.7.3.tgz#c2cb27dfa916ab966cceed692c67e4236813104a" + integrity sha512-t8iCnN6T3NZPFelPmjYIjCg+nhGbOdc0xcHCG40v01AWRTN49OINSt2k/u+16/2/HrI+b6Ssb8WByXUhbyHz6w== dependencies: fs-extra "^9.0.1" - msgpackr "^0.5.0" + msgpackr "^0.5.3" nan "^2.14.1" node-gyp-build "^4.2.3" - weak-lru-cache "^0.2.0" + weak-lru-cache "^0.3.9" + +lmdb-store@^0.8.15: + version "0.8.15" + resolved "https://registry.yarnpkg.com/lmdb-store/-/lmdb-store-0.8.15.tgz#4efb0341c2df505dd6f3a7f26f834f0a142a80a2" + integrity sha512-4Q0WZh2FmcJC6esZRUWMfkCmNiz0WU9cOgrxt97ZMTnVfHyOdZhtrt0oOF5EQPfetxxJf/BorKY28aX92R6G6g== + dependencies: + fs-extra "^9.0.1" + lmdb-store-0.9 "0.7.3" + msgpackr "^0.5.4" + nan "^2.14.1" + node-gyp-build "^4.2.3" + weak-lru-cache "^0.3.9" load-bmfont@^1.3.1, load-bmfont@^1.4.0: version "1.4.0" @@ -19748,7 +19742,7 @@ memory-fs@^0.5.0: errno "^0.1.3" readable-stream "^2.0.1" -meow@^3.3.0, meow@^3.7.0: +meow@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= @@ -20330,20 +20324,20 @@ ms@2.1.1, ms@^2.0.0, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -msgpackr-extract@^0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-0.3.4.tgz#8ee5e73d1135340e564c498e8c593134365eb060" - integrity sha512-d3+qwTJzgqqsq2L2sQuH0SoO4StvpUhMqMAKy6tMimn7XdBaRtDlquFzRJsp0iMGt2hnU4UOqD8Tz9mb0KglTA== +msgpackr-extract@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-0.3.5.tgz#0f206da058bd3dad0f8605d324de001a8f4de967" + integrity sha512-zHhstybu+m/j3H6CVBMcILVIzATK6dWRGtlePJjsnSAj8kLT5joMa9i0v21Uc80BPNDcwFsnG/dz2318tfI81w== dependencies: nan "^2.14.1" node-gyp-build "^4.2.3" -msgpackr@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-0.5.1.tgz#7eecbf342645b7718dd2e3386894368d06732b3f" - integrity sha512-nK2uJl67Q5KU3MWkYBUlYynqKS1UUzJ5M1h6TQejuJtJzD3hW2Suv2T1pf01E9lUEr93xaLokf/xC+jwBShMPQ== +msgpackr@^0.5.3, msgpackr@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-0.5.4.tgz#c21c03d5e132d2e54d0b9ced02a75b1f48413380" + integrity sha512-ILEWtIWwd5ESWHKoVjJ4GP7JWkpuAUJ20qi2j2qEC6twecBmK4E6YG3QW847OpmvdAhMJGq2LoDJRn/kNERTeQ== optionalDependencies: - msgpackr-extract "^0.3.4" + msgpackr-extract "^0.3.5" multicast-dns-service-types@^1.1.0: version "1.1.0" @@ -29142,10 +29136,10 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -weak-lru-cache@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-0.2.0.tgz#447379ccff6dfda1b7a9566c9ef168260be859d1" - integrity sha512-M1l5CzKvM7maa7tCbtL0NW6sOnp8gqup853+9Aq7GL0XNWKNnFOkeE3v3Z5X2IeMzedPwQyPbi4RlFvD6rxs7A== +weak-lru-cache@^0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-0.3.9.tgz#9e56920d4115e8542625d8ef8cc278cbd97f7624" + integrity sha512-WqAu3wzbHQvjSi/vgYhidZkf2p7L3Z8iDEIHnqvE31EQQa7Vh7PDOphrRJ1oxlW8JIjgr2HvMcRe9Q1GhW2NPw== web-namespaces@^1.0.0: version "1.1.4"