diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index dbfbe90ec9263e..029ee9ea4faf6c 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -85,6 +85,7 @@ yarn kbn watch-bazel - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-io-ts-alerting-types - @kbn/securitysolution-io-ts-list-types diff --git a/docs/maps/index.asciidoc b/docs/maps/index.asciidoc index 45d24bfb5a7e4f..20320c5a938c9e 100644 --- a/docs/maps/index.asciidoc +++ b/docs/maps/index.asciidoc @@ -24,8 +24,8 @@ Create beautiful maps from your geographical data. With **Maps**, you can: diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 9c054fbc002223..134d9de3f49d88 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -39,8 +39,8 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. |=== | `xpack.fleet.agents.fleet_server.hosts` | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.host` - | The hostname used by {agent} for accessing {es}. +| `xpack.fleet.agents.elasticsearch.hosts` + | Hostnames used by {agent} for accessing {es}. |=== [NOTE] diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index c5718b2a089bfc..3b3a7a9ee527d4 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -10,8 +10,8 @@ src="https://play.vidyard.com/embed/v4.js"> diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index 38435708aaf997..25780d303eec41 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -26,8 +26,8 @@ which features. diff --git a/package.json b/package.json index e0bebcaacd7eaa..a2499d85247d73 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", "@elastic/charts": "29.2.0", - "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", + "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", "@elastic/eui": "33.0.0", @@ -111,7 +111,7 @@ "@elastic/numeral": "^2.5.1", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/accept": "^5.0.2", @@ -124,39 +124,39 @@ "@hapi/inert": "^6.0.3", "@hapi/podium": "^4.1.1", "@hapi/wreck": "^17.1.0", - "@kbn/ace": "link:bazel-bin/packages/kbn-ace/npm_module", - "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics/npm_module", - "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", - "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", - "@kbn/config": "link:bazel-bin/packages/kbn-config/npm_module", - "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", - "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto/npm_module", - "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module", + "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/config": "link:bazel-bin/packages/kbn-config", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", + "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", + "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", - "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module", - "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", - "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module", - "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", - "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", - "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module", - "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module", - "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module", - "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module", - "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module", - "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module", - "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module", - "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils/npm_module", - "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools/npm_module", + "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", + "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", + "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", + "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", + "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", + "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", + "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", + "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", + "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", - "@kbn/std": "link:bazel-bin/packages/kbn-std/npm_module", - "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", + "@kbn/std": "link:bazel-bin/packages/kbn-std", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", - "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module", - "@kbn/utils": "link:bazel-bin/packages/kbn-utils/npm_module", + "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", + "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", "@mapbox/geojson-rewind": "^0.5.0", @@ -241,7 +241,6 @@ "get-port": "^5.0.0", "getopts": "^2.2.5", "getos": "^3.1.0", - "git-url-parse": "11.1.2", "github-markdown-css": "^2.10.0", "glob": "^7.1.2", "glob-all": "^3.2.1", @@ -294,7 +293,7 @@ "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", - "mini-css-extract-plugin": "0.8.0", + "mini-css-extract-plugin": "1.1.0", "minimatch": "^3.0.4", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", @@ -447,28 +446,28 @@ "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module", + "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", - "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", + "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", - "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module", - "@kbn/es": "link:bazel-bin/packages/kbn-es/npm_module", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", + "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", + "@kbn/es": "link:bazel-bin/packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", - "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module", - "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module", - "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", + "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", + "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect", "@kbn/optimizer": "link:packages/kbn-optimizer", - "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator/npm_module", + "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", "@kbn/storybook": "link:packages/kbn-storybook", - "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools/npm_module", + "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", "@loaders.gl/polyfills": "^2.3.5", @@ -533,7 +532,6 @@ "@types/geojson": "7946.0.7", "@types/getopts": "^2.0.1", "@types/getos": "^3.0.0", - "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.2", "@types/gulp": "^4.0.6", "@types/gulp-zip": "^4.0.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index de3498da1a6976..083ae90a031f50 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -28,6 +28,7 @@ filegroup( "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", "//packages/kbn-plugin-generator:build", + "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", "//packages/kbn-securitysolution-io-ts-alerting-types:build", diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel index c1576ce59aa5c0..dcdc042d7b8029 100644 --- a/packages/kbn-babel-code-parser/BUILD.bazel +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -34,10 +34,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-es/BUILD.bazel b/packages/kbn-es/BUILD.bazel index 6c845996ce5e5a..48f0fb58e983fc 100644 --- a/packages/kbn-es/BUILD.bazel +++ b/packages/kbn-es/BUILD.bazel @@ -47,10 +47,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel index 82e6200e9688a8..b7eb91a451b9a5 100644 --- a/packages/kbn-expect/BUILD.bazel +++ b/packages/kbn-expect/BUILD.bazel @@ -5,7 +5,7 @@ PKG_REQUIRE_NAME = "@kbn/expect" SOURCE_FILES = glob([ "expect.js", - "expect.js.d.ts", + "expect.d.ts", ]) SRCS = SOURCE_FILES diff --git a/packages/kbn-expect/expect.js.d.ts b/packages/kbn-expect/expect.d.ts similarity index 100% rename from packages/kbn-expect/expect.js.d.ts rename to packages/kbn-expect/expect.d.ts diff --git a/packages/kbn-expect/package.json b/packages/kbn-expect/package.json index 8ca37c7c88673f..2040683c539e2d 100644 --- a/packages/kbn-expect/package.json +++ b/packages/kbn-expect/package.json @@ -1,6 +1,7 @@ { "name": "@kbn/expect", "main": "./expect.js", + "typings": "./expect.d.ts", "version": "1.0.0", "license": "MIT", "private": true, diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index 7baae093bc3a98..8c0d9f1e34bd0c 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -4,6 +4,6 @@ "incremental": false, }, "include": [ - "expect.js.d.ts" + "expect.d.ts" ] } diff --git a/packages/kbn-legacy-logging/BUILD.bazel b/packages/kbn-legacy-logging/BUILD.bazel index 21cb8c338f89f9..1fd04604dbd244 100644 --- a/packages/kbn-legacy-logging/BUILD.bazel +++ b/packages/kbn-legacy-logging/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ SRC_DEPS = [ "//packages/kbn-config-schema", + "//packages/kbn-utils", "@npm//@elastic/numeral", "@npm//@hapi/hapi", "@npm//chokidar", diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel index 3a25568dfd811d..325187cdebc3ab 100644 --- a/packages/kbn-monaco/BUILD.bazel +++ b/packages/kbn-monaco/BUILD.bazel @@ -48,7 +48,7 @@ webpack( name = "target_web", data = DEPS + [ ":src", - ":webpack.config.js", + "webpack.config.js", ], output_dir = True, args = [ @@ -87,7 +87,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":target_web", ":tsc"], + deps = DEPS + [":tsc", ":target_web"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-monaco/webpack.config.js b/packages/kbn-monaco/webpack.config.js index d035134565463e..ef482cd55159bf 100644 --- a/packages/kbn-monaco/webpack.config.js +++ b/packages/kbn-monaco/webpack.config.js @@ -22,7 +22,6 @@ const createLangWorkerConfig = (lang) => { filename: `${lang}.editor.worker.js`, }, resolve: { - modules: ['node_modules'], extensions: ['.js', '.ts', '.tsx'], }, stats: 'errors-only', diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 29c0457c316f06..4c4c0259f066b8 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -23045,7 +23045,7 @@ class Project { ensureValidProjectDependency(project) { const relativePathToProject = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}/npm_module`)); + const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}`)); const versionInPackageJson = this.allDependencies[project.name]; const expectedVersionInPackageJson = `link:${relativePathToProject}`; const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages @@ -23234,7 +23234,7 @@ function transformDependencies(dependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/package_json.ts b/packages/kbn-pm/src/utils/package_json.ts index e635c2566e65ac..a50d8994b5720a 100644 --- a/packages/kbn-pm/src/utils/package_json.ts +++ b/packages/kbn-pm/src/utils/package_json.ts @@ -61,7 +61,7 @@ export function transformDependencies(dependencies: IPackageDependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/project.ts b/packages/kbn-pm/src/utils/project.ts index 5d2a0547b25772..8e86b111c6a182 100644 --- a/packages/kbn-pm/src/utils/project.ts +++ b/packages/kbn-pm/src/utils/project.ts @@ -94,7 +94,7 @@ export class Project { const relativePathToProjectIfBazelPkg = normalizePath( Path.relative( this.path, - `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}/npm_module` + `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}` ) ); diff --git a/packages/kbn-rule-data-utils/BUILD.bazel b/packages/kbn-rule-data-utils/BUILD.bazel new file mode 100644 index 00000000000000..ccd1793feb161e --- /dev/null +++ b/packages/kbn-rule-data-utils/BUILD.bazel @@ -0,0 +1,79 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-rule-data-utils" +PKG_REQUIRE_NAME = "@kbn/rule-data-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//tslib", + "@npm//utility-types", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-rule-data-utils/tsconfig.json b/packages/kbn-rule-data-utils/tsconfig.json index 4b1262d11f3aff..852393f01e5941 100644 --- a/packages/kbn-rule-data-utils/tsconfig.json +++ b/packages/kbn-rule-data-utils/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-rule-data-utils/src", "types": [ diff --git a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel index 91e4667c16b4e9..99df07c3d8ea8a 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel +++ b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel @@ -27,9 +27,10 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/elastic-datemath", "//packages/kbn-securitysolution-io-ts-types", "//packages/kbn-securitysolution-io-ts-utils", - "//packages/elastic-datemath", + "//packages/kbn-securitysolution-list-constants", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts new file mode 100644 index 00000000000000..1f61788e556503 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const applyExportTransformsMock = jest.fn(); +jest.doMock('./apply_export_transforms', () => ({ + applyExportTransforms: applyExportTransformsMock, +})); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.ts new file mode 100644 index 00000000000000..0929ff0d40910d --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.test.ts @@ -0,0 +1,528 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { applyExportTransformsMock } from './collect_exported_objects.test.mocks'; +import { savedObjectsClientMock } from '../../mocks'; +import { httpServerMock } from '../../http/http_server.mocks'; +import { SavedObject, SavedObjectError } from '../../../types'; +import type { SavedObjectsExportTransform } from './types'; +import { collectExportedObjects } from './collect_exported_objects'; + +const createObject = (parts: Partial): SavedObject => ({ + id: 'id', + type: 'type', + references: [], + attributes: {}, + ...parts, +}); + +const createError = (parts: Partial = {}): SavedObjectError => ({ + error: 'error', + message: 'message', + statusCode: 404, + ...parts, +}); + +const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id }); + +describe('collectExportedObjects', () => { + let savedObjectsClient: ReturnType; + let request: ReturnType; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + request = httpServerMock.createKibanaRequest(); + applyExportTransformsMock.mockImplementation(({ objects }) => objects); + savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] }); + }); + + afterEach(() => { + applyExportTransformsMock.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + }); + + describe('when `includeReferences` is `true`', () => { + it('calls `applyExportTransforms` with the correct parameters', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const fooTransform: SavedObjectsExportTransform = jest.fn(); + + await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: { foo: fooTransform }, + includeReferences: true, + }); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(1); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [obj1, obj2], + transforms: { foo: fooTransform }, + request, + }); + }); + + it('returns the collected objects', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple)); + }); + + it('returns the missing references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + { + type: 'missing', + id: '1', + name: 'missing-1', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'missing', + id: '2', + name: 'missing-2', + }, + ], + }); + const missing1 = createObject({ + type: 'missing', + id: '1', + error: createError(), + }); + const missing2 = createObject({ + type: 'missing', + id: '2', + error: createError(), + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2, missing1], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [missing2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple)); + expect(objects.map(toIdTuple)).toEqual([foo1, bar2].map(toIdTuple)); + }); + + it('does not call `client.bulkGet` when no objects have references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + { + type: 'foo', + id: '2', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + + it('calls `applyExportTransforms` for each iteration', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [toIdTuple(bar2)], + expect.any(Object) + ); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(2); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [foo1], + transforms: {}, + request, + }); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [bar2], + transforms: {}, + request, + }); + }); + + it('ignores references that are already included in the export', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + ], + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [foo1, dolly3], + }); + + const { objects } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(dolly3)], + expect.any(Object) + ); + + expect(objects.map(toIdTuple)).toEqual([foo1, bar2, dolly3].map(toIdTuple)); + }); + + it('does not fetch duplicates of references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [dolly3, baz4], + }); + + await collectExportedObjects({ + objects: [foo1, bar2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [dolly3, baz4].map(toIdTuple), + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'baz', id: '4' }, + { type: 'dolly', id: '3' }, + ], + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform of nested references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + // first call for foo-1 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects]); + // second call for bar-2 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [baz4], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(baz4)], + expect.any(Object) + ); + }); + }); + + describe('when `includeReferences` is `false`', () => { + it('does not fetch the object references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + id: '2', + type: 'bar', + name: 'bar-2', + }, + ], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: false, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.ts b/src/core/server/saved_objects/export/collect_exported_objects.ts new file mode 100644 index 00000000000000..d45782a83c2844 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '../../../types'; +import type { KibanaRequest } from '../../http'; +import { SavedObjectsClientContract } from '../types'; +import type { SavedObjectsExportTransform } from './types'; +import { applyExportTransforms } from './apply_export_transforms'; + +interface CollectExportedObjectOptions { + savedObjectsClient: SavedObjectsClientContract; + objects: SavedObject[]; + /** flag to also include all related saved objects in the export stream. */ + includeReferences?: boolean; + /** optional namespace to override the namespace used by the savedObjectsClient. */ + namespace?: string; + /** The http request initiating the export. */ + request: KibanaRequest; + /** export transform per type */ + exportTransforms: Record; +} + +interface CollectExportedObjectResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +export const collectExportedObjects = async ({ + objects, + includeReferences = true, + namespace, + request, + exportTransforms, + savedObjectsClient, +}: CollectExportedObjectOptions): Promise => { + const collectedObjects: SavedObject[] = []; + const collectedMissingRefs: CollectedReference[] = []; + const alreadyProcessed: Set = new Set(); + + let currentObjects = objects; + do { + const transformed = ( + await applyExportTransforms({ + request, + objects: currentObjects, + transforms: exportTransforms, + }) + ).filter((object) => !alreadyProcessed.has(objKey(object))); + + transformed.forEach((obj) => alreadyProcessed.add(objKey(obj))); + collectedObjects.push(...transformed); + + if (includeReferences) { + const references = collectReferences(transformed, alreadyProcessed); + if (references.length) { + const { objects: fetchedObjects, missingRefs } = await fetchReferences({ + references, + namespace, + client: savedObjectsClient, + }); + collectedMissingRefs.push(...missingRefs); + currentObjects = fetchedObjects; + } else { + currentObjects = []; + } + } else { + currentObjects = []; + } + } while (includeReferences && currentObjects.length); + + return { + objects: collectedObjects, + missingRefs: collectedMissingRefs, + }; +}; + +const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; + +type ObjectKey = string; + +interface CollectedReference { + id: string; + type: string; +} + +const collectReferences = ( + objects: SavedObject[], + alreadyProcessed: Set +): CollectedReference[] => { + const references: Map = new Map(); + objects.forEach((obj) => { + obj.references?.forEach((ref) => { + const refKey = objKey(ref); + if (!alreadyProcessed.has(refKey)) { + references.set(refKey, { type: ref.type, id: ref.id }); + } + }); + }); + return [...references.values()]; +}; + +interface FetchReferencesResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +const fetchReferences = async ({ + references, + client, + namespace, +}: { + references: CollectedReference[]; + client: SavedObjectsClientContract; + namespace?: string; +}): Promise => { + const { saved_objects: savedObjects } = await client.bulkGet(references, { namespace }); + return { + objects: savedObjects.filter((obj) => !obj.error), + missingRefs: savedObjects + .filter((obj) => obj.error) + .map((obj) => ({ type: obj.type, id: obj.id })), + }; +}; diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts deleted file mode 100644 index a47c629f9066b1..00000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SavedObject } from '../types'; -import { savedObjectsClientMock } from '../../mocks'; -import { getObjectReferencesToFetch, fetchNestedDependencies } from './fetch_nested_dependencies'; -import { SavedObjectsErrorHelpers } from '..'; - -describe('getObjectReferencesToFetch()', () => { - test('works with no saved objects', () => { - const map = new Map(); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('excludes already fetched objects', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('returns objects that are missing', () => { - const map = new Map(); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ] - `); - }); - - test('does not fail on circular dependencies', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'visualization', - id: '2', - }, - ], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); -}); - -describe('injectNestedDependencies', () => { - const savedObjectsClient = savedObjectsClientMock.create(); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test(`doesn't fetch when no dependencies are missing`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test(`doesn't fetch references that are already fetched`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - ], - } - `); - }); - - test('fetches dependencies at least one level deep', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('fetches dependencies multiple levels deep', async () => { - const savedObjects = [ - { - id: '5', - type: 'dashboard', - attributes: {}, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '4', - }, - { - name: 'panel_1', - type: 'visualization', - id: '3', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '4', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - { - id: '3', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ], - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "5", - "references": Array [ - Object { - "id": "4", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - Object { - "attributes": Object {}, - "id": "4", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "3", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "4", - "type": "visualization", - }, - Object { - "id": "3", - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - Array [ - Array [ - Object { - "id": "2", - "type": "search", - }, - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('returns list of missing references', async () => { - const savedObjects = [ - { - id: '1', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - { - name: 'ref_1', - type: 'index-pattern', - id: '2', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output - .payload, - attributes: {}, - references: [], - }, - { - id: '2', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - Object { - "id": "2", - "name": "ref_1", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test('does not fail on circular dependencies', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.ts deleted file mode 100644 index 778c01804b8939..00000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SavedObject, SavedObjectsClientContract } from '../types'; - -export function getObjectReferencesToFetch(savedObjectsMap: Map) { - const objectsToFetch = new Map(); - for (const savedObject of savedObjectsMap.values()) { - for (const ref of savedObject.references || []) { - if (!savedObjectsMap.has(objKey(ref))) { - objectsToFetch.set(objKey(ref), { type: ref.type, id: ref.id }); - } - } - } - return [...objectsToFetch.values()]; -} - -export async function fetchNestedDependencies( - savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract, - namespace?: string -) { - const savedObjectsMap = new Map(); - for (const savedObject of savedObjects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - let objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - while (objectsToFetch.length > 0) { - const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch, { namespace }); - // Push to array result - for (const savedObject of bulkGetResponse.saved_objects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - } - const allObjects = [...savedObjectsMap.values()]; - return { - objects: allObjects.filter((obj) => !obj.error), - missingRefs: allObjects - .filter((obj) => !!obj.error) - .map((obj) => ({ type: obj.type, id: obj.id })), - }; -} - -const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 8cd6934bf1af9c..9d56bb4872a6dc 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -12,7 +12,6 @@ import { Logger } from '../../logging'; import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; -import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; import { SavedObjectsExportResultDetails, @@ -22,7 +21,7 @@ import { SavedObjectsExportTransform, } from './types'; import { SavedObjectsExportError } from './errors'; -import { applyExportTransforms } from './apply_export_transforms'; +import { collectExportedObjects } from './collect_exported_objects'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -118,28 +117,21 @@ export class SavedObjectsExporter { }: SavedObjectExportBaseOptions ) { this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); - let exportedObjects: Array>; - let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; - savedObjects = await applyExportTransforms({ - request, + const { + objects: collectedObjects, + missingRefs: missingReferences, + } = await collectExportedObjects({ objects: savedObjects, - transforms: this.#exportTransforms, - sortFunction, + includeReferences: includeReferencesDeep, + namespace, + request, + exportTransforms: this.#exportTransforms, + savedObjectsClient: this.#savedObjectsClient, }); - if (includeReferencesDeep) { - this.#log.debug(`Fetching saved objects references.`); - const fetchResult = await fetchNestedDependencies( - savedObjects, - this.#savedObjectsClient, - namespace - ); - exportedObjects = sortObjects(fetchResult.objects); - missingReferences = fetchResult.missingRefs; - } else { - exportedObjects = sortObjects(savedObjects); - } + // sort with the provided sort function then with the default export sorting + const exportedObjects = sortObjects(collectedObjects.sort(sortFunction)); // redact attributes that should not be exported const redactedObjects = includeNamespaces diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts new file mode 100644 index 00000000000000..8ff9591798fd4a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; + +describe('bulkOverwriteTransformedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts new file mode 100644 index 00000000000000..830a8efccc7eba --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; + +/** @internal */ +export interface BulkOverwriteTransformedDocumentsParams { + client: ElasticsearchClient; + index: string; + transformedDocs: SavedObjectsRawDoc[]; + refresh?: estypes.Refresh; +} +/** + * Write the up-to-date transformed documents to the index, overwriting any + * documents that are still on their outdated version. + */ +export const bulkOverwriteTransformedDocuments = ({ + client, + index, + transformedDocs, + refresh = false, +}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< + RetryableEsClientError, + 'bulk_index_succeeded' +> => () => { + return client + .bulk({ + // Because we only add aliases in the MARK_VERSION_INDEX_READY step we + // can't bulkIndex to an alias with require_alias=true. This means if + // users tamper during this operation (delete indices or restore a + // snapshot), we could end up auto-creating an index without the correct + // mappings. Such tampering could lead to many other problems and is + // probably unlikely so for now we'll accept this risk and wait till + // system indices puts in place a hard control. + require_alias: false, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + refresh, + filter_path: ['items.*.error'], + body: transformedDocs.flatMap((doc) => { + return [ + { + index: { + _index: index, + _id: doc._id, + // overwrite existing documents + op_type: 'index', + // use optimistic concurrency control to ensure that outdated + // documents are only overwritten once with the latest version + if_seq_no: doc._seq_no, + if_primary_term: doc._primary_term, + }, + }, + doc._source, + ]; + }), + }) + .then((res) => { + // Filter out version_conflict_engine_exception since these just mean + // that another instance already updated these documents + const errors = (res.body.items ?? []).filter( + (item) => item.index?.error?.type !== 'version_conflict_engine_exception' + ); + if (errors.length === 0) { + return Either.right('bulk_index_succeeded' as const); + } else { + throw new Error(JSON.stringify(errors)); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts new file mode 100644 index 00000000000000..84b4b00bc7e7fd --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { cloneIndex } from './clone_index'; +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('cloneIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = cloneIndex({ + client, + source: 'my_source_index', + target: 'my_target_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts new file mode 100644 index 00000000000000..5674535c803287 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound, AcknowledgeResponse } from './'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +export type CloneIndexResponse = AcknowledgeResponse; + +/** @internal */ +export interface CloneIndexParams { + client: ElasticsearchClient; + source: string; + target: string; + /** only used for testing */ + timeout?: string; +} +/** + * Makes a clone of the source index into the target. + * + * @remarks + * This method adds some additional logic to the ES clone index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first clone operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const cloneIndex = ({ + client, + source, + target, + timeout = DEFAULT_TIMEOUT, +}: CloneIndexParams): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + CloneIndexResponse +> => { + const cloneTask: TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + AcknowledgeResponse + > = () => { + return client.indices + .clone( + { + index: source, + target, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + body: { + settings: { + index: { + // The source we're cloning from will have a write block set, so + // we need to remove it to allow writes to our newly cloned index + 'blocks.write': false, + number_of_shards: INDEX_NUMBER_OF_SHARDS, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + timeout, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated with the newly created index, but it probably will be + * created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, cloning complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error: EsErrors.ResponseError) => { + if (error?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: error.body.error.index, + }); + } else if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous clone + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + cloneTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right(res); + } else { + // Otherwise, wait until the target index has a 'green' status. + return pipe( + waitForIndexStatusYellow({ client, index: target, timeout }), + TaskEither.map((value) => { + /** When the index status is 'green' we know that all shards were started */ + return { acknowledged: true, shardsAcknowledged: true }; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts new file mode 100644 index 00000000000000..5d9696239a61e3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { closePit } from './close_pit'; + +describe('closePit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = closePit({ client, pitId: 'pitId' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts new file mode 100644 index 00000000000000..d421950c839e23 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface ClosePitParams { + client: ElasticsearchClient; + pitId: string; +} +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ({ + client, + pitId, +}: ClosePitParams): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/constants.ts b/src/core/server/saved_objects/migrationsv2/actions/constants.ts new file mode 100644 index 00000000000000..5d0d2ffe5d695b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Batch size for updateByQuery and reindex operations. + * Uses the default value of 1000 for Elasticsearch reindex operation. + */ +export const BATCH_SIZE = 1_000; +export const DEFAULT_TIMEOUT = '60s'; +/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ +export const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; +/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ +export const INDEX_NUMBER_OF_SHARDS = 1; +/** Wait for all shards to be active before starting an operation */ +export const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts new file mode 100644 index 00000000000000..d5d906898943cc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { createIndex } from './create_index'; +import { setWriteBlock } from './set_write_block'; + +describe('createIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = createIndex({ + client, + indexName: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts new file mode 100644 index 00000000000000..47ee44e762db79 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import type { estypes } from '@elastic/elasticsearch'; +import { AcknowledgeResponse } from './index'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; + +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} + +/** @internal */ +export interface CreateIndexParams { + client: ElasticsearchClient; + indexName: string; + mappings: IndexMapping; + aliases?: string[]; +} +/** + * Creates an index with the given mappings + * + * @remarks + * This method adds some additional logic to the ES create index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first create operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const createIndex = ({ + client, + indexName, + mappings, + aliases = [], +}: CreateIndexParams): TaskEither.TaskEither => { + const createIndexTask: TaskEither.TaskEither< + RetryableEsClientError, + AcknowledgeResponse + > = () => { + const aliasesObject = aliasArrayToRecord(aliases); + + return client.indices + .create( + { + index: indexName, + // wait until all shards are available before creating the index + // (since number_of_shards=1 this does not have any effect atm) + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + // Wait up to 60s for the cluster state to update and all shards to be + // started + timeout: DEFAULT_TIMEOUT, + body: { + mappings, + aliases: aliasesObject, + settings: { + index: { + // ES rule of thumb: shards should be several GB to 10's of GB, so + // Kibana is unlikely to cross that limit. + number_of_shards: 1, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated on all nodes with the newly created index, but it + * probably will be created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, index creation complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error) => { + if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous create + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + createIndexTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right('create_index_succeeded'); + } else { + // Otherwise, wait until the target index has a 'yellow' status. + return pipe( + waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), + TaskEither.map(() => { + /** When the index status is 'yellow' we know that all shards were started */ + return 'create_index_succeeded'; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts new file mode 100644 index 00000000000000..0dab1728b6ef2d --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { fetchIndices } from './fetch_indices'; + +describe('fetchIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = fetchIndices({ client, indices: ['my_index'] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts new file mode 100644 index 00000000000000..3847252eb6db15 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Either from 'fp-ts/lib/Either'; +import { IndexMapping } from '../../mappings'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +export type FetchIndexResponse = Record< + string, + { aliases: Record; mappings: IndexMapping; settings: unknown } +>; + +/** @internal */ +export interface FetchIndicesParams { + client: ElasticsearchClient; + indices: string[]; +} + +/** + * Fetches information about the given indices including aliases, mappings and + * settings. + */ +export const fetchIndices = ({ + client, + indices, +}: FetchIndicesParams): TaskEither.TaskEither => + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required + () => { + return client.indices + .get( + { + index: indices, + ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 + }, + { ignore: [404], maxRetries: 0 } + ) + .then(({ body }) => { + return Either.right(body); + }) + .catch(catchRetryableEsClientErrors); + }; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts deleted file mode 100644 index 05da335d708840..00000000000000 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import * as Actions from './'; -import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -jest.mock('./catch_retryable_es_client_errors'); -import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; -import * as Option from 'fp-ts/lib/Option'; - -describe('actions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - // Create a mock client that rejects all methods with a 503 status code - // response. - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); - - const nonRetryableError = new Error('crash'); - const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) - ); - - describe('fetchIndices', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.fetchIndices({ client, indices: ['my_index'] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('setWriteBlock', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.setWriteBlock({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('cloneIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.cloneIndex({ - client, - source: 'my_source_index', - target: 'my_target_index', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('pickupUpdatedMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.pickupUpdatedMappings(client, 'my_index'); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('openPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.openPit({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('readWithPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.readWithPit({ - client, - pitId: 'pitId', - query: { match_all: {} }, - batchSize: 10_000, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('closePit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.closePit({ client, pitId: 'pitId' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('reindex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.reindex({ - client, - sourceIndex: 'my_source_index', - targetIndex: 'my_target_index', - reindexScript: Option.none, - requireAlias: false, - unusedTypesQuery: {}, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('waitForReindexTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('waitForPickupUpdatedMappingsTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForPickupUpdatedMappingsTask({ - client, - taskId: 'my task id', - timeout: '60s', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAliases', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAliases({ client, aliasActions: [] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('createIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.createIndex({ - client, - indexName: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAndPickupMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAndPickupMappings({ - client, - index: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('searchForOutdatedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.searchForOutdatedDocuments(client, { - batchSize: 1000, - targetIndex: 'new_index', - outdatedDocumentsQuery: {}, - }); - - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - - it('configures request according to given parameters', async () => { - const esClient = elasticsearchClientMock.createInternalClient(); - const query = {}; - const targetIndex = 'new_index'; - const batchSize = 1000; - const task = Actions.searchForOutdatedDocuments(esClient, { - batchSize, - targetIndex, - outdatedDocumentsQuery: query, - }); - - await task(); - - expect(esClient.search).toHaveBeenCalledTimes(1); - expect(esClient.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: targetIndex, - size: batchSize, - body: expect.objectContaining({ query }), - }) - ); - }); - }); - - describe('bulkOverwriteTransformedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments({ - client, - index: 'new_index', - transformedDocs: [], - refresh: 'wait_for', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('refreshIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.refreshIndex({ client, targetIndex: 'target_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); -}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 905d64947298ec..98d7167ffc31a1 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -6,1231 +6,126 @@ * Side Public License, v 1. */ -import * as Either from 'fp-ts/lib/Either'; -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import type { estypes } from '@elastic/elasticsearch'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { flow } from 'fp-ts/lib/function'; -import { ElasticsearchClient } from '../../../elasticsearch'; -import { IndexMapping } from '../../mappings'; -import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; -import type { TransformRawDocs } from '../types'; -import { - catchRetryableEsClientErrors, - RetryableEsClientError, -} from './catch_retryable_es_client_errors'; -import { - DocumentsTransformFailed, - DocumentsTransformSuccess, -} from '../../migrations/core/migrate_raw_docs'; -export type { RetryableEsClientError }; - -/** - * Batch size for updateByQuery and reindex operations. - * Uses the default value of 1000 for Elasticsearch reindex operation. - */ -const BATCH_SIZE = 1_000; -const DEFAULT_TIMEOUT = '60s'; -/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ -const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; -/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ -const INDEX_NUMBER_OF_SHARDS = 1; -/** Wait for all shards to be active before starting an operation */ -const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; - -// Map of left response 'type' string -> response interface -export interface ActionErrorTypeMap { - wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; - retryable_es_client_error: RetryableEsClientError; - index_not_found_exception: IndexNotFound; - target_index_had_write_block: TargetIndexHadWriteBlock; - incompatible_mapping_exception: IncompatibleMappingException; - alias_not_found_exception: AliasNotFound; - remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; - documents_transform_failed: DocumentsTransformFailed; -} - -/** - * Type guard for narrowing the type of a left - */ -export function isLeftTypeof( - res: any, - typeString: T -): res is ActionErrorTypeMap[T] { - return res.type === typeString; -} - -export type FetchIndexResponse = Record< - string, - { aliases: Record; mappings: IndexMapping; settings: unknown } ->; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import { DocumentsTransformFailed } from '../../migrations/core/migrate_raw_docs'; -/** @internal */ -export interface FetchIndicesParams { - client: ElasticsearchClient; - indices: string[]; -} - -/** - * Fetches information about the given indices including aliases, mappings and - * settings. - */ -export const fetchIndices = ({ - client, - indices, -}: FetchIndicesParams): TaskEither.TaskEither => - // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required - () => { - return client.indices - .get( - { - index: indices, - ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 - }, - { ignore: [404], maxRetries: 0 } - ) - .then(({ body }) => { - return Either.right(body); - }) - .catch(catchRetryableEsClientErrors); - }; +export { + BATCH_SIZE, + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; -export interface IndexNotFound { - type: 'index_not_found_exception'; - index: string; -} +export type { RetryableEsClientError }; -/** @internal */ -export interface SetWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Sets a write block in place for the given index. If the response includes - * `acknowledged: true` all in-progress writes have drained and no further - * writes to this index will be possible. - * - * The first time the write block is added to an index the response will - * include `shards_acknowledged: true` but once the block is in place, - * subsequent calls return `shards_acknowledged: false` - */ -export const setWriteBlock = ({ - client, - index, -}: SetWriteBlockParams): TaskEither.TaskEither< - IndexNotFound | RetryableEsClientError, - 'set_write_block_succeeded' -> => () => { - return ( - client.indices - .addBlock<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - block: 'write', - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - // not typed yet - .then((res: any) => { - return res.body.acknowledged === true - ? Either.right('set_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'set_write_block_failed', - }); - }) - .catch((e: ElasticsearchClientError) => { - if (e instanceof EsErrors.ResponseError) { - if (e.body?.error?.type === 'index_not_found_exception') { - return Either.left({ type: 'index_not_found_exception' as const, index }); - } - } - throw e; - }) - .catch(catchRetryableEsClientErrors) - ); -}; +// actions/* imports +export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; +export { fetchIndices } from './fetch_indices'; -/** @internal */ -export interface RemoveWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Removes a write block from an index - */ -export const removeWriteBlock = ({ - client, - index, -}: RemoveWriteBlockParams): TaskEither.TaskEither< - RetryableEsClientError, - 'remove_write_block_succeeded' -> => () => { - return client.indices - .putSettings<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - // Don't change any existing settings - preserve_existing: true, - body: { - index: { - blocks: { - write: false, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - return res.body.acknowledged === true - ? Either.right('remove_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'remove_write_block_failed', - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { SetWriteBlockParams } from './set_write_block'; +export { setWriteBlock } from './set_write_block'; -/** @internal */ -export interface WaitForIndexStatusYellowParams { - client: ElasticsearchClient; - index: string; - timeout?: string; -} -/** - * A yellow index status means the index's primary shard is allocated and the - * index is ready for searching/indexing documents, but ES wasn't able to - * allocate the replicas. When migrations proceed with a yellow index it means - * we don't have as much data-redundancy as we could have, but waiting for - * replicas would mean that v2 migrations fail where v1 migrations would have - * succeeded. It doesn't feel like it's Kibana's job to force users to keep - * their clusters green and even if it's green when we migrate it can turn - * yellow at any point in the future. So ultimately data-redundancy is up to - * users to maintain. - */ -export const waitForIndexStatusYellow = ({ - client, - index, - timeout = DEFAULT_TIMEOUT, -}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { - return client.cluster - .health({ index, wait_for_status: 'yellow', timeout }) - .then(() => { - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RemoveWriteBlockParams } from './remove_write_block'; +export { removeWriteBlock } from './remove_write_block'; -export type CloneIndexResponse = AcknowledgeResponse; +export type { CloneIndexResponse, CloneIndexParams } from './clone_index'; +export { cloneIndex } from './clone_index'; -/** @internal */ -export interface CloneIndexParams { - client: ElasticsearchClient; - source: string; - target: string; - /** only used for testing */ - timeout?: string; -} -/** - * Makes a clone of the source index into the target. - * - * @remarks - * This method adds some additional logic to the ES clone index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first clone operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const cloneIndex = ({ - client, - source, - target, - timeout = DEFAULT_TIMEOUT, -}: CloneIndexParams): TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - CloneIndexResponse -> => { - const cloneTask: TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - AcknowledgeResponse - > = () => { - return client.indices - .clone( - { - index: source, - target, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - body: { - settings: { - index: { - // The source we're cloning from will have a write block set, so - // we need to remove it to allow writes to our newly cloned index - 'blocks.write': false, - number_of_shards: INDEX_NUMBER_OF_SHARDS, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - timeout, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated with the newly created index, but it probably will be - * created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, cloning complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error: EsErrors.ResponseError) => { - if (error?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: error.body.error.index, - }); - } else if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous clone - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; +export type { WaitForIndexStatusYellowParams } from './wait_for_index_status_yellow'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; - return pipe( - cloneTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right(res); - } else { - // Otherwise, wait until the target index has a 'green' status. - return pipe( - waitForIndexStatusYellow({ client, index: target, timeout }), - TaskEither.map((value) => { - /** When the index status is 'green' we know that all shards were started */ - return { acknowledged: true, shardsAcknowledged: true }; - }) - ); - } - }) - ); -}; +export type { WaitForTaskResponse, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; -interface WaitForTaskResponse { - error: Option.Option<{ type: string; reason: string; index: string }>; - completed: boolean; - failures: Option.Option; - description?: string; -} +export type { UpdateByQueryResponse } from './pickup_updated_mappings'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; -/** - * After waiting for the specificed timeout, the task has not yet completed. - * - * When querying the tasks API we use `wait_for_completion=true` to block the - * request until the task completes. If after the `timeout`, the task still has - * not completed we return this error. This does not mean that the task itelf - * has reached a timeout, Elasticsearch will continue to run the task. - */ -export interface WaitForTaskCompletionTimeout { - /** After waiting for the specificed timeout, the task has not yet completed. */ - readonly type: 'wait_for_task_completion_timeout'; - readonly message: string; - readonly error?: Error; -} +export type { OpenPitResponse, OpenPitParams } from './open_pit'; +export { openPit, pitKeepAlive } from './open_pit'; -const catchWaitForTaskCompletionTimeout = ( - e: ResponseError -): Either.Either => { - if ( - e.body?.error?.type === 'timeout_exception' || - e.body?.error?.type === 'receive_timeout_transport_exception' - ) { - return Either.left({ - type: 'wait_for_task_completion_timeout' as const, - message: `[${e.body.error.type}] ${e.body.error.reason}`, - error: e, - }); - } else { - throw e; - } -}; +export type { ReadWithPit, ReadWithPitParams } from './read_with_pit'; +export { readWithPit } from './read_with_pit'; -/** @internal */ -export interface WaitForTaskParams { - client: ElasticsearchClient; - taskId: string; - timeout: string; -} -/** - * Blocks for up to 60s or until a task completes. - * - * TODO: delete completed tasks - */ -const waitForTask = ({ - client, - taskId, - timeout, -}: WaitForTaskParams): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - WaitForTaskResponse -> => () => { - return client.tasks - .get({ - task_id: taskId, - wait_for_completion: true, - timeout, - }) - .then((res) => { - const body = res.body; - const failures = body.response?.failures ?? []; - return Either.right({ - completed: body.completed, - // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property - error: Option.fromNullable(body.error), - failures: failures.length > 0 ? Option.some(failures) : Option.none, - description: body.task.description, - }); - }) - .catch(catchWaitForTaskCompletionTimeout) - .catch(catchRetryableEsClientErrors); -}; +export type { ClosePitParams } from './close_pit'; +export { closePit } from './close_pit'; -export interface UpdateByQueryResponse { - taskId: string; -} +export type { TransformDocsParams } from './transform_docs'; +export { transformDocs } from './transform_docs'; -/** - * Pickup updated mappings by performing an update by query operation on all - * documents in the index. Returns a task ID which can be - * tracked for progress. - * - * @remarks When mappings are updated to add a field which previously wasn't - * mapped Elasticsearch won't automatically add existing documents to it's - * internal search indices. So search results on this field won't return any - * existing documents. By running an update by query we essentially refresh - * these the internal search indices for all existing documents. - * This action uses `conflicts: 'proceed'` allowing several Kibana instances - * to run this in parallel. - */ -export const pickupUpdatedMappings = ( - client: ElasticsearchClient, - index: string -): TaskEither.TaskEither => () => { - return client - .updateByQuery({ - // Ignore version conflicts that can occur from parallel update by query operations - conflicts: 'proceed', - // Return an error when targeting missing or closed indices - allow_no_indices: false, - index, - // How many documents to update per batch - scroll_size: BATCH_SIZE, - // force a refresh so that we can query the updated index immediately - // after the operation completes - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId!) }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RefreshIndexParams } from './refresh_index'; +export { refreshIndex } from './refresh_index'; -/** @internal */ -export interface OpenPitResponse { - pitId: string; -} +export type { ReindexResponse, ReindexParams } from './reindex'; +export { reindex } from './reindex'; -/** @internal */ -export interface OpenPitParams { - client: ElasticsearchClient; - index: string; -} -// how long ES should keep PIT alive -const pitKeepAlive = '10m'; -/* - * Creates a lightweight view of data when the request has been initiated. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const openPit = ({ - client, - index, -}: OpenPitParams): TaskEither.TaskEither => () => { - return client - .openPointInTime({ - index, - keep_alive: pitKeepAlive, - }) - .then((response) => Either.right({ pitId: response.body.id })) - .catch(catchRetryableEsClientErrors); -}; +import type { IncompatibleMappingException } from './wait_for_reindex_task'; +export { waitForReindexTask } from './wait_for_reindex_task'; -/** @internal */ -export interface ReadWithPit { - outdatedDocuments: SavedObjectsRawDoc[]; - readonly lastHitSortValue: number[] | undefined; - readonly totalHits: number | undefined; -} +export type { VerifyReindexParams } from './verify_reindex'; +export { verifyReindex } from './verify_reindex'; -/** @internal */ +import type { AliasNotFound, RemoveIndexNotAConcreteIndex } from './update_aliases'; +export type { AliasAction, UpdateAliasesParams } from './update_aliases'; +export { updateAliases } from './update_aliases'; -export interface ReadWithPitParams { - client: ElasticsearchClient; - pitId: string; - query: estypes.QueryContainer; - batchSize: number; - searchAfter?: number[]; - seqNoPrimaryTerm?: boolean; -} +export type { CreateIndexParams } from './create_index'; +export { createIndex } from './create_index'; -/* - * Requests documents from the index using PIT mechanism. - * */ -export const readWithPit = ({ - client, - pitId, - query, - batchSize, - searchAfter, - seqNoPrimaryTerm, -}: ReadWithPitParams): TaskEither.TaskEither => () => { - return client - .search({ - seq_no_primary_term: seqNoPrimaryTerm, - body: { - // Sort fields are required to use searchAfter - sort: { - // the most efficient option as order is not important for the migration - _shard_doc: { order: 'asc' }, - }, - pit: { id: pitId, keep_alive: pitKeepAlive }, - size: batchSize, - search_after: searchAfter, - /** - * We want to know how many documents we need to process so we can log the progress. - * But we also want to increase the performance of these requests, - * so we ask ES to report the total count only on the first request (when searchAfter does not exist) - */ - track_total_hits: typeof searchAfter === 'undefined', - query, - }, - }) - .then((response) => { - const totalHits = - typeof response.body.hits.total === 'number' - ? response.body.hits.total // This format is to be removed in 8.0 - : response.body.hits.total?.value; - const hits = response.body.hits.hits; +export type { + UpdateAndPickupMappingsResponse, + UpdateAndPickupMappingsParams, +} from './update_and_pickup_mappings'; +export { updateAndPickupMappings } from './update_and_pickup_mappings'; - if (hits.length > 0) { - return Either.right({ - // @ts-expect-error @elastic/elasticsearch _source is optional - outdatedDocuments: hits as SavedObjectsRawDoc[], - lastHitSortValue: hits[hits.length - 1].sort as number[], - totalHits, - }); - } +export { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; - return Either.right({ - outdatedDocuments: [], - lastHitSortValue: undefined, - totalHits, - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { + SearchResponse, + SearchForOutdatedDocumentsOptions, +} from './search_for_outdated_documents'; +export { searchForOutdatedDocuments } from './search_for_outdated_documents'; -/** @internal */ -export interface ClosePitParams { - client: ElasticsearchClient; - pitId: string; -} -/* - * Closes PIT. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const closePit = ({ - client, - pitId, -}: ClosePitParams): TaskEither.TaskEither => () => { - return client - .closePointInTime({ - body: { id: pitId }, - }) - .then((response) => { - if (!response.body.succeeded) { - throw new Error(`Failed to close PointInTime with id: ${pitId}`); - } - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { BulkOverwriteTransformedDocumentsParams } from './bulk_overwrite_transformed_documents'; +export { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; -/** @internal */ -export interface TransformDocsParams { - transformRawDocs: TransformRawDocs; - outdatedDocuments: SavedObjectsRawDoc[]; -} -/* - * Transform outdated docs - * */ -export const transformDocs = ({ - transformRawDocs, - outdatedDocuments, -}: TransformDocsParams): TaskEither.TaskEither< - DocumentsTransformFailed, - DocumentsTransformSuccess -> => transformRawDocs(outdatedDocuments); +export { pickupUpdatedMappings, waitForTask, waitForIndexStatusYellow }; +export type { AliasNotFound, RemoveIndexNotAConcreteIndex }; -/** @internal */ -export interface ReindexResponse { - taskId: string; -} - -/** @internal */ -export interface RefreshIndexParams { - client: ElasticsearchClient; - targetIndex: string; -} -/** - * Wait for Elasticsearch to reindex all the changes. - */ -export const refreshIndex = ({ - client, - targetIndex, -}: RefreshIndexParams): TaskEither.TaskEither< - RetryableEsClientError, - { refreshed: boolean } -> => () => { - return client.indices - .refresh({ - index: targetIndex, - }) - .then(() => { - return Either.right({ refreshed: true }); - }) - .catch(catchRetryableEsClientErrors); -}; -/** @internal */ -export interface ReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; - reindexScript: Option.Option; - requireAlias: boolean; - /* When reindexing we use a source query to exclude saved objects types which - * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be available in the upgraded index. - */ - unusedTypesQuery: estypes.QueryContainer; +export interface IndexNotFound { + type: 'index_not_found_exception'; + index: string; } -/** - * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a - * task ID which can be tracked for progress. - * - * @remarks This action is idempotent allowing several Kibana instances to run - * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there - * will be only one write per reindexed document. - */ -export const reindex = ({ - client, - sourceIndex, - targetIndex, - reindexScript, - requireAlias, - unusedTypesQuery, -}: ReindexParams): TaskEither.TaskEither => () => { - return client - .reindex({ - // Require targetIndex to be an alias. Prevents a new index from being - // created if targetIndex doesn't exist. - require_alias: requireAlias, - body: { - // Ignore version conflicts from existing documents - conflicts: 'proceed', - source: { - index: sourceIndex, - // Set reindex batch size - size: BATCH_SIZE, - // Exclude saved object types - query: unusedTypesQuery, - }, - dest: { - index: targetIndex, - // Don't override existing documents, only create if missing - op_type: 'create', - }, - script: Option.fold( - () => undefined, - (script) => ({ - source: script, - lang: 'painless', - }) - )(reindexScript), - }, - // force a refresh so that we can query the target index - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId) }); - }) - .catch(catchRetryableEsClientErrors); -}; - -interface WaitForReindexTaskFailure { +export interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } - -/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } -/** @internal */ -export interface IncompatibleMappingException { - type: 'incompatible_mapping_exception'; -} - -export const waitForReindexTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - | IndexNotFound - | TargetIndexHadWriteBlock - | IncompatibleMappingException - | RetryableEsClientError - | WaitForTaskCompletionTimeout, - 'reindex_succeeded' - > => { - const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); - - const failureIsIncompatibleMappingException = ({ - cause: { type, reason }, - }: WaitForReindexTaskFailure) => - type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; - - if (Option.isSome(res.error)) { - if (res.error.value.type === 'index_not_found_exception') { - return TaskEither.left({ - type: 'index_not_found_exception' as const, - index: res.error.value.index, - }); - } else { - throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); - } - } else if (Option.isSome(res.failures)) { - if (res.failures.value.every(failureIsAWriteBlock)) { - return TaskEither.left({ type: 'target_index_had_write_block' as const }); - } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { - return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); - } else { - throw new Error( - 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) - ); - } - } else { - return TaskEither.right('reindex_succeeded' as const); - } - } - ) -); - -/** @internal */ -export interface VerifyReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; -} - -export const verifyReindex = ({ - client, - sourceIndex, - targetIndex, -}: VerifyReindexParams): TaskEither.TaskEither< - RetryableEsClientError | { type: 'verify_reindex_failed' }, - 'verify_reindex_succeeded' -> => () => { - const count = (index: string) => - client - .count<{ count: number }>({ - index, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - }) - .then((res) => { - return res.body.count; - }); - - return Promise.all([count(sourceIndex), count(targetIndex)]) - .then(([sourceCount, targetCount]) => { - if (targetCount >= sourceCount) { - return Either.right('verify_reindex_succeeded' as const); - } else { - return Either.left({ type: 'verify_reindex_failed' as const }); - } - }) - .catch(catchRetryableEsClientErrors); -}; - -export const waitForPickupUpdatedMappingsTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - 'pickup_updated_mappings_succeeded' - > => { - // We don't catch or type failures/errors because they should never - // occur in our migration algorithm and we don't have any business logic - // for dealing with it. If something happens we'll just crash and try - // again. - if (Option.isSome(res.failures)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following failures:\n' + - JSON.stringify(res.failures.value) - ); - } else if (Option.isSome(res.error)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following error:\n' + - JSON.stringify(res.error.value) - ); - } else { - return TaskEither.right('pickup_updated_mappings_succeeded' as const); - } - } - ) -); -export interface AliasNotFound { - type: 'alias_not_found_exception'; -} - -/** @internal */ -export interface RemoveIndexNotAConcreteIndex { - type: 'remove_index_not_a_concrete_index'; -} - -/** @internal */ -export type AliasAction = - | { remove_index: { index: string } } - | { remove: { index: string; alias: string; must_exist: boolean } } - | { add: { index: string; alias: string } }; - -/** @internal */ -export interface UpdateAliasesParams { - client: ElasticsearchClient; - aliasActions: AliasAction[]; -} -/** - * Calls the Update index alias API `_alias` with the provided alias actions. - */ -export const updateAliases = ({ - client, - aliasActions, -}: UpdateAliasesParams): TaskEither.TaskEither< - IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, - 'update_aliases_succeeded' -> => () => { - return client.indices - .updateAliases( - { - body: { - actions: aliasActions, - }, - }, - { maxRetries: 0 } - ) - .then(() => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // The only impact for using `updateAliases` to mark the version index - // as ready is that it could take longer for other Kibana instances to - // see that the version index is ready so they are more likely to - // perform unecessary duplicate work. - return Either.right('update_aliases_succeeded' as const); - }) - .catch((err: EsErrors.ElasticsearchClientError) => { - if (err instanceof EsErrors.ResponseError) { - if (err?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: err.body.error.index, - }); - } else if ( - err?.body?.error?.type === 'illegal_argument_exception' && - err?.body?.error?.reason?.match( - /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ - ) - ) { - return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); - } else if ( - err?.body?.error?.type === 'aliases_not_found_exception' || - (err?.body?.error?.type === 'resource_not_found_exception' && - err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) - ) { - return Either.left({ - type: 'alias_not_found_exception' as const, - }); - } - } - throw err; - }) - .catch(catchRetryableEsClientErrors); -}; - /** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } - -function aliasArrayToRecord(aliases: string[]): Record { - const result: Record = {}; - for (const alias of aliases) { - result[alias] = {}; - } - return result; -} - -/** @internal */ -export interface CreateIndexParams { - client: ElasticsearchClient; - indexName: string; - mappings: IndexMapping; - aliases?: string[]; -} -/** - * Creates an index with the given mappings - * - * @remarks - * This method adds some additional logic to the ES create index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first create operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const createIndex = ({ - client, - indexName, - mappings, - aliases = [], -}: CreateIndexParams): TaskEither.TaskEither => { - const createIndexTask: TaskEither.TaskEither< - RetryableEsClientError, - AcknowledgeResponse - > = () => { - const aliasesObject = aliasArrayToRecord(aliases); - - return client.indices - .create( - { - index: indexName, - // wait until all shards are available before creating the index - // (since number_of_shards=1 this does not have any effect atm) - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait up to 60s for the cluster state to update and all shards to be - // started - timeout: DEFAULT_TIMEOUT, - body: { - mappings, - aliases: aliasesObject, - settings: { - index: { - // ES rule of thumb: shards should be several GB to 10's of GB, so - // Kibana is unlikely to cross that limit. - number_of_shards: 1, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated on all nodes with the newly created index, but it - * probably will be created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, index creation complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error) => { - if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous create - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - createIndexTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right('create_index_succeeded'); - } else { - // Otherwise, wait until the target index has a 'yellow' status. - return pipe( - waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), - TaskEither.map(() => { - /** When the index status is 'yellow' we know that all shards were started */ - return 'create_index_succeeded'; - }) - ); - } - }) - ); -}; - -/** @internal */ -export interface UpdateAndPickupMappingsResponse { - taskId: string; -} - -/** @internal */ -export interface UpdateAndPickupMappingsParams { - client: ElasticsearchClient; - index: string; - mappings: IndexMapping; -} -/** - * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping - * changes are "picked up". Returns a taskId to track progress. - */ -export const updateAndPickupMappings = ({ - client, - index, - mappings, -}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< - RetryableEsClientError, - UpdateAndPickupMappingsResponse -> => { - const putMappingTask: TaskEither.TaskEither< - RetryableEsClientError, - 'update_mappings_succeeded' - > = () => { - return client.indices - .putMapping({ - index, - timeout: DEFAULT_TIMEOUT, - body: mappings, - }) - .then((res) => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // For updateAndPickupMappings this means that there is the potential - // that some existing document's fields won't be picked up if the node - // on which the Kibana shard is running has fallen behind with cluster - // state updates and the mapping update wasn't applied before we run - // `pickupUpdatedMappings`. ES tries to limit this risk by blocking - // index operations (including update_by_query used by - // updateAndPickupMappings) if there are pending mappings changes. But - // not all mapping changes will prevent this. - return Either.right('update_mappings_succeeded' as const); - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - putMappingTask, - TaskEither.chain((res) => { - return pickupUpdatedMappings(client, index); - }) - ); -}; - -/** @internal */ -export interface SearchResponse { - outdatedDocuments: SavedObjectsRawDoc[]; -} - -interface SearchForOutdatedDocumentsOptions { - batchSize: number; - targetIndex: string; - outdatedDocumentsQuery?: estypes.QueryContainer; +// Map of left response 'type' string -> response interface +export interface ActionErrorTypeMap { + wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; + retryable_es_client_error: RetryableEsClientError; + index_not_found_exception: IndexNotFound; + target_index_had_write_block: TargetIndexHadWriteBlock; + incompatible_mapping_exception: IncompatibleMappingException; + alias_not_found_exception: AliasNotFound; + remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; + documents_transform_failed: DocumentsTransformFailed; } /** - * Search for outdated saved object documents with the provided query. Will - * return one batch of documents. Searching should be repeated until no more - * outdated documents can be found. - * - * Used for testing only + * Type guard for narrowing the type of a left */ -export const searchForOutdatedDocuments = ( - client: ElasticsearchClient, - options: SearchForOutdatedDocumentsOptions -): TaskEither.TaskEither => () => { - return client - .search({ - index: options.targetIndex, - // Return the _seq_no and _primary_term so we can use optimistic - // concurrency control for updates - seq_no_primary_term: true, - size: options.batchSize, - body: { - query: options.outdatedDocumentsQuery, - // Optimize search performance by sorting by the "natural" index order - sort: ['_doc'], - }, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - // Don't return partial results if timeouts or shard failures are - // encountered. This is important because 0 search hits is interpreted as - // there being no more outdated documents left that require - // transformation. Although the default is `false`, we set this - // explicitly to avoid users overriding the - // search.default_allow_partial_results cluster setting to true. - allow_partial_search_results: false, - // Improve performance by not calculating the total number of hits - // matching the query. - track_total_hits: false, - // Reduce the response payload size by only returning the data we care about - filter_path: [ - 'hits.hits._id', - 'hits.hits._source', - 'hits.hits._seq_no', - 'hits.hits._primary_term', - ], - }) - .then((res) => - Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) - ) - .catch(catchRetryableEsClientErrors); -}; - -/** @internal */ -export interface BulkOverwriteTransformedDocumentsParams { - client: ElasticsearchClient; - index: string; - transformedDocs: SavedObjectsRawDoc[]; - refresh?: estypes.Refresh; +export function isLeftTypeof( + res: any, + typeString: T +): res is ActionErrorTypeMap[T] { + return res.type === typeString; } -/** - * Write the up-to-date transformed documents to the index, overwriting any - * documents that are still on their outdated version. - */ -export const bulkOverwriteTransformedDocuments = ({ - client, - index, - transformedDocs, - refresh = false, -}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< - RetryableEsClientError, - 'bulk_index_succeeded' -> => () => { - return client - .bulk({ - // Because we only add aliases in the MARK_VERSION_INDEX_READY step we - // can't bulkIndex to an alias with require_alias=true. This means if - // users tamper during this operation (delete indices or restore a - // snapshot), we could end up auto-creating an index without the correct - // mappings. Such tampering could lead to many other problems and is - // probably unlikely so for now we'll accept this risk and wait till - // system indices puts in place a hard control. - require_alias: false, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - refresh, - filter_path: ['items.*.error'], - body: transformedDocs.flatMap((doc) => { - return [ - { - index: { - _index: index, - _id: doc._id, - // overwrite existing documents - op_type: 'index', - // use optimistic concurrency control to ensure that outdated - // documents are only overwritten once with the latest version - if_seq_no: doc._seq_no, - if_primary_term: doc._primary_term, - }, - }, - doc._source, - ]; - }), - }) - .then((res) => { - // Filter out version_conflict_engine_exception since these just mean - // that another instance already updated these documents - const errors = (res.body.items ?? []).filter( - (item) => item.index?.error?.type !== 'version_conflict_engine_exception' - ); - if (errors.length === 0) { - return Either.right('bulk_index_succeeded' as const); - } else { - throw new Error(JSON.stringify(errors)); - } - }) - .catch(catchRetryableEsClientErrors); -}; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts rename to src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 67a2685caf3d61..b508a6198bfb34 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { ElasticsearchClient } from '../../../'; -import { InternalCoreStart } from '../../../internal_types'; -import * as kbnTestServer from '../../../../test_helpers/kbn_server'; -import { Root } from '../../../root'; -import { SavedObjectsRawDoc } from '../../serialization'; +import { ElasticsearchClient } from '../../../../'; +import { InternalCoreStart } from '../../../../internal_types'; +import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; +import { Root } from '../../../../root'; +import { SavedObjectsRawDoc } from '../../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, @@ -37,11 +37,11 @@ import { removeWriteBlock, transformDocs, waitForIndexStatusYellow, -} from '../actions'; +} from '../../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../migrations/core'; +import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../../migrations/core'; import { TaskEither } from 'fp-ts/lib/TaskEither'; const { startES } = kbnTestServer.createTestServers({ diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts new file mode 100644 index 00000000000000..c8fc29d06f42f7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { openPit } from './open_pit'; + +describe('openPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = openPit({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts new file mode 100644 index 00000000000000..e740dc00ac27ea --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +/** @internal */ +export interface OpenPitParams { + client: ElasticsearchClient; + index: string; +} +// how long ES should keep PIT alive +export const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ({ + client, + index, +}: OpenPitParams): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts new file mode 100644 index 00000000000000..e319d4149dd1af --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; + +describe('pickupUpdatedMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = pickupUpdatedMappings(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts new file mode 100644 index 00000000000000..8cc609e5277bcc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; +export interface UpdateByQueryResponse { + taskId: string; +} + +/** + * Pickup updated mappings by performing an update by query operation on all + * documents in the index. Returns a task ID which can be + * tracked for progress. + * + * @remarks When mappings are updated to add a field which previously wasn't + * mapped Elasticsearch won't automatically add existing documents to it's + * internal search indices. So search results on this field won't return any + * existing documents. By running an update by query we essentially refresh + * these the internal search indices for all existing documents. + * This action uses `conflicts: 'proceed'` allowing several Kibana instances + * to run this in parallel. + */ +export const pickupUpdatedMappings = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .updateByQuery({ + // Ignore version conflicts that can occur from parallel update by query operations + conflicts: 'proceed', + // Return an error when targeting missing or closed indices + allow_no_indices: false, + index, + // How many documents to update per batch + scroll_size: BATCH_SIZE, + // force a refresh so that we can query the updated index immediately + // after the operation completes + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId!) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts new file mode 100644 index 00000000000000..0d8d76b45a57b3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { readWithPit } from './read_with_pit'; + +describe('readWithPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = readWithPit({ + client, + pitId: 'pitId', + query: { match_all: {} }, + batchSize: 10_000, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts new file mode 100644 index 00000000000000..16f1df05f26b3e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pitKeepAlive } from './open_pit'; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; + readonly totalHits: number | undefined; +} + +/** @internal */ +export interface ReadWithPitParams { + client: ElasticsearchClient; + pitId: string; + query: estypes.QueryContainer; + batchSize: number; + searchAfter?: number[]; + seqNoPrimaryTerm?: boolean; +} + +/* + * Requests documents from the index using PIT mechanism. + * */ +export const readWithPit = ({ + client, + pitId, + query, + batchSize, + searchAfter, + seqNoPrimaryTerm, +}: ReadWithPitParams): TaskEither.TaskEither => () => { + return client + .search({ + seq_no_primary_term: seqNoPrimaryTerm, + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + /** + * We want to know how many documents we need to process so we can log the progress. + * But we also want to increase the performance of these requests, + * so we ask ES to report the total count only on the first request (when searchAfter does not exist) + */ + track_total_hits: typeof searchAfter === 'undefined', + query, + }, + }) + .then((response) => { + const totalHits = + typeof response.body.hits.total === 'number' + ? response.body.hits.total // This format is to be removed in 8.0 + : response.body.hits.total?.value; + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + totalHits, + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + totalHits, + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts new file mode 100644 index 00000000000000..0ebdb2b2b18519 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { refreshIndex } from './refresh_index'; + +describe('refreshIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = refreshIndex({ client, targetIndex: 'target_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts new file mode 100644 index 00000000000000..e7bcbfb7d2d532 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; + +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RefreshIndexParams { + client: ElasticsearchClient; + targetIndex: string; +} +/** + * Wait for Elasticsearch to reindex all the changes. + */ +export const refreshIndex = ({ + client, + targetIndex, +}: RefreshIndexParams): TaskEither.TaskEither< + RetryableEsClientError, + { refreshed: boolean } +> => () => { + return client.indices + .refresh({ + index: targetIndex, + }) + .then(() => { + return Either.right({ refreshed: true }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts new file mode 100644 index 00000000000000..f53368bd9321b6 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Option from 'fp-ts/lib/Option'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { reindex } from './reindex'; + +describe('reindex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = reindex({ + client, + sourceIndex: 'my_source_index', + targetIndex: 'my_target_index', + reindexScript: Option.none, + requireAlias: false, + unusedTypesQuery: {}, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts new file mode 100644 index 00000000000000..ca8d3b594703c3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; + +/** @internal */ +export interface ReindexResponse { + taskId: string; +} +/** @internal */ +export interface ReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; + reindexScript: Option.Option; + requireAlias: boolean; + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: estypes.QueryContainer; +} +/** + * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a + * task ID which can be tracked for progress. + * + * @remarks This action is idempotent allowing several Kibana instances to run + * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there + * will be only one write per reindexed document. + */ +export const reindex = ({ + client, + sourceIndex, + targetIndex, + reindexScript, + requireAlias, + unusedTypesQuery, +}: ReindexParams): TaskEither.TaskEither => () => { + return client + .reindex({ + // Require targetIndex to be an alias. Prevents a new index from being + // created if targetIndex doesn't exist. + require_alias: requireAlias, + body: { + // Ignore version conflicts from existing documents + conflicts: 'proceed', + source: { + index: sourceIndex, + // Set reindex batch size + size: BATCH_SIZE, + // Exclude saved object types + query: unusedTypesQuery, + }, + dest: { + index: targetIndex, + // Don't override existing documents, only create if missing + op_type: 'create', + }, + script: Option.fold( + () => undefined, + (script) => ({ + source: script, + lang: 'painless', + }) + )(reindexScript), + }, + // force a refresh so that we can query the target index + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts new file mode 100644 index 00000000000000..497211cb693ab1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { removeWriteBlock } from './remove_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('removeWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = removeWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = removeWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts new file mode 100644 index 00000000000000..c55e4a235fbf10 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RemoveWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Removes a write block from an index + */ +export const removeWriteBlock = ({ + client, + index, +}: RemoveWriteBlockParams): TaskEither.TaskEither< + RetryableEsClientError, + 'remove_write_block_succeeded' +> => () => { + return client.indices + .putSettings<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + // Don't change any existing settings + preserve_existing: true, + body: { + index: { + blocks: { + write: false, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + return res.body.acknowledged === true + ? Either.right('remove_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'remove_write_block_failed', + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts new file mode 100644 index 00000000000000..ab133e9a564be7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { searchForOutdatedDocuments } from './search_for_outdated_documents'; + +describe('searchForOutdatedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = searchForOutdatedDocuments(client, { + batchSize: 1000, + targetIndex: 'new_index', + outdatedDocumentsQuery: {}, + }); + + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('configures request according to given parameters', async () => { + const esClient = elasticsearchClientMock.createInternalClient(); + const query = {}; + const targetIndex = 'new_index'; + const batchSize = 1000; + const task = searchForOutdatedDocuments(esClient, { + batchSize, + targetIndex, + outdatedDocumentsQuery: query, + }); + + await task(); + + expect(esClient.search).toHaveBeenCalledTimes(1); + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: targetIndex, + size: batchSize, + body: expect.objectContaining({ query }), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts new file mode 100644 index 00000000000000..7406cd35b1593e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface SearchResponse { + outdatedDocuments: SavedObjectsRawDoc[]; +} + +export interface SearchForOutdatedDocumentsOptions { + batchSize: number; + targetIndex: string; + outdatedDocumentsQuery?: estypes.QueryContainer; +} + +/** + * Search for outdated saved object documents with the provided query. Will + * return one batch of documents. Searching should be repeated until no more + * outdated documents can be found. + * + * Used for testing only + */ +export const searchForOutdatedDocuments = ( + client: ElasticsearchClient, + options: SearchForOutdatedDocumentsOptions +): TaskEither.TaskEither => () => { + return client + .search({ + index: options.targetIndex, + // Return the _seq_no and _primary_term so we can use optimistic + // concurrency control for updates + seq_no_primary_term: true, + size: options.batchSize, + body: { + query: options.outdatedDocumentsQuery, + // Optimize search performance by sorting by the "natural" index order + sort: ['_doc'], + }, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + // Don't return partial results if timeouts or shard failures are + // encountered. This is important because 0 search hits is interpreted as + // there being no more outdated documents left that require + // transformation. Although the default is `false`, we set this + // explicitly to avoid users overriding the + // search.default_allow_partial_results cluster setting to true. + allow_partial_search_results: false, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Reduce the response payload size by only returning the data we care about + filter_path: [ + 'hits.hits._id', + 'hits.hits._source', + 'hits.hits._seq_no', + 'hits.hits._primary_term', + ], + }) + .then((res) => + Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) + ) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts new file mode 100644 index 00000000000000..cf7b3091f38ffc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('setWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = setWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts new file mode 100644 index 00000000000000..5aed316306cf91 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ElasticsearchClientError } from '@elastic/elasticsearch/lib/errors'; +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound } from './'; + +/** @internal */ +export interface SetWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Sets a write block in place for the given index. If the response includes + * `acknowledged: true` all in-progress writes have drained and no further + * writes to this index will be possible. + * + * The first time the write block is added to an index the response will + * include `shards_acknowledged: true` but once the block is in place, + * subsequent calls return `shards_acknowledged: false` + */ +export const setWriteBlock = ({ + client, + index, +}: SetWriteBlockParams): TaskEither.TaskEither< + IndexNotFound | RetryableEsClientError, + 'set_write_block_succeeded' +> => () => { + return ( + client.indices + .addBlock<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + block: 'write', + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + // not typed yet + .then((res: any) => { + return res.body.acknowledged === true + ? Either.right('set_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'set_write_block_failed', + }); + }) + .catch((e: ElasticsearchClientError) => { + if (e instanceof EsErrors.ResponseError) { + if (e.body?.error?.type === 'index_not_found_exception') { + return Either.left({ type: 'index_not_found_exception' as const, index }); + } + } + throw e; + }) + .catch(catchRetryableEsClientErrors) + ); +}; +// diff --git a/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts new file mode 100644 index 00000000000000..4c712afcff3a45 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { TransformRawDocs } from '../types'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + DocumentsTransformFailed, + DocumentsTransformSuccess, +} from '../../migrations/core/migrate_raw_docs'; + +/** @internal */ +export interface TransformDocsParams { + transformRawDocs: TransformRawDocs; + outdatedDocuments: SavedObjectsRawDoc[]; +} +/* + * Transform outdated docs + * */ +export const transformDocs = ({ + transformRawDocs, + outdatedDocuments, +}: TransformDocsParams): TaskEither.TaskEither< + DocumentsTransformFailed, + DocumentsTransformSuccess +> => transformRawDocs(outdatedDocuments); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts new file mode 100644 index 00000000000000..e2ea07d40281bf --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAliases } from './update_aliases'; +import { setWriteBlock } from './set_write_block'; + +describe('updateAliases', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAliases({ client, aliasActions: [] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts new file mode 100644 index 00000000000000..ffb8002f092123 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { IndexNotFound } from './index'; + +export interface AliasNotFound { + type: 'alias_not_found_exception'; +} + +/** @internal */ +export interface RemoveIndexNotAConcreteIndex { + type: 'remove_index_not_a_concrete_index'; +} + +/** @internal */ +export type AliasAction = + | { remove_index: { index: string } } + | { remove: { index: string; alias: string; must_exist: boolean } } + | { add: { index: string; alias: string } }; + +/** @internal */ +export interface UpdateAliasesParams { + client: ElasticsearchClient; + aliasActions: AliasAction[]; +} +/** + * Calls the Update index alias API `_alias` with the provided alias actions. + */ +export const updateAliases = ({ + client, + aliasActions, +}: UpdateAliasesParams): TaskEither.TaskEither< + IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, + 'update_aliases_succeeded' +> => () => { + return client.indices + .updateAliases( + { + body: { + actions: aliasActions, + }, + }, + { maxRetries: 0 } + ) + .then(() => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // The only impact for using `updateAliases` to mark the version index + // as ready is that it could take longer for other Kibana instances to + // see that the version index is ready so they are more likely to + // perform unecessary duplicate work. + return Either.right('update_aliases_succeeded' as const); + }) + .catch((err: EsErrors.ElasticsearchClientError) => { + if (err instanceof EsErrors.ResponseError) { + if (err?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: err.body.error.index, + }); + } else if ( + err?.body?.error?.type === 'illegal_argument_exception' && + err?.body?.error?.reason?.match( + /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ + ) + ) { + return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); + } else if ( + err?.body?.error?.type === 'aliases_not_found_exception' || + (err?.body?.error?.type === 'resource_not_found_exception' && + err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) + ) { + return Either.left({ + type: 'alias_not_found_exception' as const, + }); + } + } + throw err; + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts new file mode 100644 index 00000000000000..3ecb990cd9e825 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAndPickupMappings } from './update_and_pickup_mappings'; + +describe('updateAndPickupMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAndPickupMappings({ + client, + index: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts new file mode 100644 index 00000000000000..8c742005a01ce1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface UpdateAndPickupMappingsResponse { + taskId: string; +} + +/** @internal */ +export interface UpdateAndPickupMappingsParams { + client: ElasticsearchClient; + index: string; + mappings: IndexMapping; +} +/** + * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping + * changes are "picked up". Returns a taskId to track progress. + */ +export const updateAndPickupMappings = ({ + client, + index, + mappings, +}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< + RetryableEsClientError, + UpdateAndPickupMappingsResponse +> => { + const putMappingTask: TaskEither.TaskEither< + RetryableEsClientError, + 'update_mappings_succeeded' + > = () => { + return client.indices + .putMapping({ + index, + timeout: DEFAULT_TIMEOUT, + body: mappings, + }) + .then((res) => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // For updateAndPickupMappings this means that there is the potential + // that some existing document's fields won't be picked up if the node + // on which the Kibana shard is running has fallen behind with cluster + // state updates and the mapping update wasn't applied before we run + // `pickupUpdatedMappings`. ES tries to limit this risk by blocking + // index operations (including update_by_query used by + // updateAndPickupMappings) if there are pending mappings changes. But + // not all mapping changes will prevent this. + return Either.right('update_mappings_succeeded' as const); + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + putMappingTask, + TaskEither.chain((res) => { + return pickupUpdatedMappings(client, index); + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts new file mode 100644 index 00000000000000..4db599d8fbadf4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface VerifyReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; +} + +export const verifyReindex = ({ + client, + sourceIndex, + targetIndex, +}: VerifyReindexParams): TaskEither.TaskEither< + RetryableEsClientError | { type: 'verify_reindex_failed' }, + 'verify_reindex_succeeded' +> => () => { + const count = (index: string) => + client + .count<{ count: number }>({ + index, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + }) + .then((res) => { + return res.body.count; + }); + + return Promise.all([count(sourceIndex), count(targetIndex)]) + .then(([sourceCount, targetCount]) => { + if (targetCount >= sourceCount) { + return Either.right('verify_reindex_succeeded' as const); + } else { + return Either.left({ type: 'verify_reindex_failed' as const }); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts new file mode 100644 index 00000000000000..8cea34b80ffad1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForIndexStatusYellow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForIndexStatusYellow({ + client, + index: 'my_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts new file mode 100644 index 00000000000000..307c77ee5b89c7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface WaitForIndexStatusYellowParams { + client: ElasticsearchClient; + index: string; + timeout?: string; +} +/** + * A yellow index status means the index's primary shard is allocated and the + * index is ready for searching/indexing documents, but ES wasn't able to + * allocate the replicas. When migrations proceed with a yellow index it means + * we don't have as much data-redundancy as we could have, but waiting for + * replicas would mean that v2 migrations fail where v1 migrations would have + * succeeded. It doesn't feel like it's Kibana's job to force users to keep + * their clusters green and even if it's green when we migrate it can turn + * yellow at any point in the future. So ultimately data-redundancy is up to + * users to maintain. + */ +export const waitForIndexStatusYellow = ({ + client, + index, + timeout = DEFAULT_TIMEOUT, +}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { + return client.cluster + .health({ index, wait_for_status: 'yellow', timeout }) + .then(() => { + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts new file mode 100644 index 00000000000000..f7c380be9427cb --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForPickupUpdatedMappingsTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForPickupUpdatedMappingsTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts new file mode 100644 index 00000000000000..02f7c3455cec90 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; + +export const waitForPickupUpdatedMappingsTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + 'pickup_updated_mappings_succeeded' + > => { + // We don't catch or type failures/errors because they should never + // occur in our migration algorithm and we don't have any business logic + // for dealing with it. If something happens we'll just crash and try + // again. + if (Option.isSome(res.failures)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following failures:\n' + + JSON.stringify(res.failures.value) + ); + } else if (Option.isSome(res.error)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following error:\n' + + JSON.stringify(res.error.value) + ); + } else { + return TaskEither.right('pickup_updated_mappings_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts new file mode 100644 index 00000000000000..f6a236aab5c85b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForReindexTask } from './wait_for_reindex_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForReindexTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts new file mode 100644 index 00000000000000..fcadb5e80298a7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { IndexNotFound, WaitForReindexTaskFailure, TargetIndexHadWriteBlock } from './index'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; + +export interface IncompatibleMappingException { + type: 'incompatible_mapping_exception'; +} +export const waitForReindexTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + | IndexNotFound + | TargetIndexHadWriteBlock + | IncompatibleMappingException + | RetryableEsClientError + | WaitForTaskCompletionTimeout, + 'reindex_succeeded' + > => { + const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => + type === 'cluster_block_exception' && + reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); + + const failureIsIncompatibleMappingException = ({ + cause: { type, reason }, + }: WaitForReindexTaskFailure) => + type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; + + if (Option.isSome(res.error)) { + if (res.error.value.type === 'index_not_found_exception') { + return TaskEither.left({ + type: 'index_not_found_exception' as const, + index: res.error.value.index, + }); + } else { + throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); + } + } else if (Option.isSome(res.failures)) { + if (res.failures.value.every(failureIsAWriteBlock)) { + return TaskEither.left({ type: 'target_index_had_write_block' as const }); + } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { + return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); + } else { + throw new Error( + 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) + ); + } + } else { + return TaskEither.right('reindex_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts new file mode 100644 index 00000000000000..c7ca9bf36a2c62 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForTask } from './wait_for_task'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + describe('waitForPickupUpdatedMappingsTask', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts new file mode 100644 index 00000000000000..4e3631797e34bc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface WaitForTaskResponse { + error: Option.Option<{ type: string; reason: string; index: string }>; + completed: boolean; + failures: Option.Option; + description?: string; +} + +/** + * After waiting for the specificed timeout, the task has not yet completed. + * + * When querying the tasks API we use `wait_for_completion=true` to block the + * request until the task completes. If after the `timeout`, the task still has + * not completed we return this error. This does not mean that the task itelf + * has reached a timeout, Elasticsearch will continue to run the task. + */ +export interface WaitForTaskCompletionTimeout { + /** After waiting for the specificed timeout, the task has not yet completed. */ + readonly type: 'wait_for_task_completion_timeout'; + readonly message: string; + readonly error?: Error; +} + +const catchWaitForTaskCompletionTimeout = ( + e: EsErrors.ResponseError +): Either.Either => { + if ( + e.body?.error?.type === 'timeout_exception' || + e.body?.error?.type === 'receive_timeout_transport_exception' + ) { + return Either.left({ + type: 'wait_for_task_completion_timeout' as const, + message: `[${e.body.error.type}] ${e.body.error.reason}`, + error: e, + }); + } else { + throw e; + } +}; + +/** @internal */ +export interface WaitForTaskParams { + client: ElasticsearchClient; + taskId: string; + timeout: string; +} +/** + * Blocks for up to 60s or until a task completes. + * + * TODO: delete completed tasks + */ +export const waitForTask = ({ + client, + taskId, + timeout, +}: WaitForTaskParams): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + WaitForTaskResponse +> => () => { + return client.tasks + .get({ + task_id: taskId, + wait_for_completion: true, + timeout, + }) + .then((res) => { + const body = res.body; + const failures = body.response?.failures ?? []; + return Either.right({ + completed: body.completed, + // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property + error: Option.fromNullable(body.error), + failures: failures.length > 0 ? Option.some(failures) : Option.none, + description: body.task.description, + }); + }) + .catch(catchWaitForTaskCompletionTimeout) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 9a6d185ef2ac10..5851ffa045bc7f 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -32,6 +32,14 @@ export interface DashboardPanelState< panelRefName?: string; } +export interface DashboardCapabilities { + showWriteControls: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; + [key: string]: boolean; +} + /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts index 6d51422d4bd23e..895a56242bf96e 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_functions.ts +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -21,7 +21,7 @@ import { switchMap, } from 'rxjs/operators'; -import { DashboardCapabilities } from './types'; +import { DashboardAppCapabilities } from './types'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardStateManager } from './dashboard_state_manager'; import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; @@ -103,7 +103,7 @@ export const getDashboardContainerInput = ({ dashboardStateManager, dashboardCapabilities, }: { - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; incomingEmbeddable?: EmbeddablePackageState; lastReloadRequestTime?: number; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 92b0727d2458cf..847a190a6e083a 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -37,11 +37,11 @@ import { } from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; export interface DashboardContainerInput extends ContainerInput { - dashboardCapabilities?: DashboardCapabilities; + dashboardCapabilities?: DashboardAppCapabilities; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; @@ -91,7 +91,7 @@ export interface InheritedChildInput extends IndexSignature { export type DashboardReactContextValue = KibanaReactContextValue; export type DashboardReactContext = KibanaReactContext; -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 44beed5e4a89be..ae8943e9f6b3e3 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -590,10 +590,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
{ return ( - + toasts: coreMock.createStart().notifications.toasts, }); -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 022c830b180b67..febb03d58d9341 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -24,7 +24,7 @@ import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; import { DashboardListing, DashboardListingProps } from './dashboard_listing'; import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { visualizationsPluginMock } from '../../../../visualizations/public/mocks'; -import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { DashboardAppServices, DashboardAppCapabilities } from '../types'; import { dataPluginMock } from '../../../../data/public/mocks'; import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; import { I18nProvider } from '@kbn/i18n/react'; @@ -59,7 +59,7 @@ function makeDefaultServices(): DashboardAppServices { return { savedObjects: savedObjectsPluginMock.createStartContract(), embeddable: embeddablePluginMock.createInstance().doStart(), - dashboardCapabilities: {} as DashboardCapabilities, + dashboardCapabilities: {} as DashboardAppCapabilities, initializerContext: {} as PluginInitializerContext, chrome: chromeServiceMock.createStartContract(), navigation: {} as NavigationPublicPluginStart, diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 56823adf6bc142..a96b1ebd4f1ff0 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -16,7 +16,7 @@ import { SharePluginStart } from '../../services/share'; import { dashboardUrlParams } from '../dashboard_router'; import { DashboardStateManager } from '../dashboard_state_manager'; import { shareModalStrings } from '../../dashboard_strings'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; const showFilterBarId = 'showFilterBar'; @@ -24,14 +24,14 @@ interface ShowShareModalProps { share: SharePluginStart; anchorElement: HTMLElement; savedDashboard: DashboardSavedObject; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { if (!anonymousUserCapabilities.dashboard) return false; - const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardAppCapabilities; return !!dashboard.show; }; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index dd291291ce9d61..aae8a1f6eca540 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -49,7 +49,7 @@ export interface DashboardSaveOptions { isTitleDuplicateConfirmed: boolean; } -export interface DashboardCapabilities { +export interface DashboardAppCapabilities { visualizeCapabilities: { save: boolean }; mapsCapabilities: { save: boolean }; hideWriteControls: boolean; @@ -77,7 +77,7 @@ export interface DashboardAppServices { usageCollection?: UsageCollectionSetup; navigation: NavigationPublicPluginStart; dashboardPanelStorage: DashboardPanelStorage; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; initializerContext: PluginInitializerContext; onAppLeave: AppMountParameters['onAppLeave']; savedObjectsTagging?: SavedObjectsTaggingApi; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 230918399d88f8..b73fe5f2ba410d 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -65,6 +65,7 @@ import { AddToLibraryAction, LibraryNotificationAction, CopyToDashboardAction, + DashboardCapabilities, } from './application'; import { createDashboardUrlGenerator, @@ -351,6 +352,9 @@ export class DashboardPlugin const { notifications, overlays, application } = core; const { uiActions, data, share, presentationUtil, embeddable } = plugins; + const dashboardCapabilities: Readonly = application.capabilities + .dashboard as DashboardCapabilities; + const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); const expandPanelAction = new ExpandPanelAction(); @@ -395,8 +399,8 @@ export class DashboardPlugin overlays, embeddable.getStateTransfer(), { - canCreateNew: Boolean(application.capabilities.dashboard.createNew), - canEditExisting: !Boolean(application.capabilities.dashboard.hideWriteControls), + canCreateNew: Boolean(dashboardCapabilities.createNew), + canEditExisting: Boolean(dashboardCapabilities.showWriteControls), }, presentationUtil.ContextProvider ); diff --git a/src/plugins/dashboard/server/capabilities_provider.ts b/src/plugins/dashboard/server/capabilities_provider.ts index 25457c1a487d90..c5b740c5812941 100644 --- a/src/plugins/dashboard/server/capabilities_provider.ts +++ b/src/plugins/dashboard/server/capabilities_provider.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -export const capabilitiesProvider = () => ({ +import { DashboardCapabilities } from '../common/types'; + +export const capabilitiesProvider = (): { + dashboard: DashboardCapabilities; +} => ({ dashboard: { createNew: true, show: true, diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts new file mode 100644 index 00000000000000..fe0f1a766e8aca --- /dev/null +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import semverGte from 'semver/functions/gte'; +import { visualizeEmbeddableFactory } from './visualize_embeddable_factory'; +import { visualizationSavedObjectTypeMigrations } from '../migrations/visualization_saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(visualizationSavedObjectTypeMigrations).filter( + (version) => { + return semverGte(version, '7.13.1'); + } + ); + const embeddableMigrationVersions = visualizeEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json new file mode 100644 index 00000000000000..caac89461b9ef0 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json @@ -0,0 +1,87 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_1", + "source": { + "test-export-transform": { + "title": "test_1-obj_1", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-transform", + "id": "type_1-obj_2", + "name": "ref-1" + }, + { + "type": "test-export-add", + "id": "type_2-obj_1", + "name": "ref-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_2", + "source": { + "test-export-transform": { + "title": "test_1-obj_2", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add:type_2-obj_1", + "source": { + "test-export-add": { + "title": "test_2-obj_1" + }, + "type": "test-export-add", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add-dep:type_dep-obj_1", + "source": { + "test-export-add-dep": { + "title": "type_dep-obj_1" + }, + "type": "test-export-add-dep", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-add", + "id": "type_2-obj_1" + } + ] + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json new file mode 100644 index 00000000000000..43b851e817fa81 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json @@ -0,0 +1,499 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "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" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": false, + "properties": {} + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "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" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } +} diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index e3d5a7fdf57c24..99c17c632720a1 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 47cbc8c5e3ea3f..d311f752fd4907 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts index 87bf5e0584a7d7..2b845cb6327b88 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts @@ -19,122 +19,169 @@ export default function ({ getService }: PluginFunctionalProviderContext) { const esArchiver = getService('esArchiver'); describe('export transforms', () => { - before(async () => { - await esArchiver.load( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + describe('root objects export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - after(async () => { - await esArchiver.unload( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - it('allows to mutate the objects during an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ - { - id: 'type_1-obj_1', - enabled: false, - }, - { - id: 'type_1-obj_2', - enabled: false, - }, - ]); - }); - }); + it('allows to mutate the objects during an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ + { + id: 'type_1-obj_1', + enabled: false, + }, + { + id: 'type_1-obj_2', + enabled: false, + }, + ]); + }); + }); - it('allows to add additional objects to an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - objects: [ - { - type: 'test-export-add', - id: 'type_2-obj_1', - }, - ], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); - }); - }); + it('allows to add additional objects to an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-add', + id: 'type_2-obj_1', + }, + ], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); + }); + }); - it('allows to add additional objects to an export when exporting by type', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-add'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql([ - 'type_2-obj_1', - 'type_2-obj_2', - 'type_dep-obj_1', - 'type_dep-obj_2', - ]); - }); - }); + it('allows to add additional objects to an export when exporting by type', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-add'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_2-obj_1', + 'type_2-obj_2', + 'type_dep-obj_1', + 'type_dep-obj_2', + ]); + }); + }); + + it('returns a 400 when the type causes a transform error', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform-error'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + const { attributes, ...error } = resp.body; + expect(error).to.eql({ + error: 'Bad Request', + message: 'Error transforming objects to export', + statusCode: 400, + }); + expect(attributes.cause).to.eql('Error during transform'); + expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); + }); + }); - it('returns a 400 when the type causes a transform error', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform-error'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - const { attributes, ...error } = resp.body; - expect(error).to.eql({ - error: 'Bad Request', - message: 'Error transforming objects to export', - statusCode: 400, + it('returns a 400 when the type causes an invalid transform', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-invalid-transform'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'Invalid transform performed on objects to export', + statusCode: 400, + attributes: { + objectKeys: ['test-export-invalid-transform|type_3-obj_1'], + }, + }); }); - expect(attributes.cause).to.eql('Error during transform'); - expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); - }); + }); }); - it('returns a 400 when the type causes an invalid transform', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-invalid-transform'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'Invalid transform performed on objects to export', - statusCode: 400, - attributes: { - objectKeys: ['test-export-invalid-transform|type_3-obj_1'], - }, + describe('FOO nested export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + it('execute export transforms for reference objects', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-transform', + id: 'type_1-obj_1', + }, + ], + includeReferencesDeep: true, + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text).sort((obj1, obj2) => + obj1.id.localeCompare(obj2.id) + ); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_1-obj_1', + 'type_1-obj_2', + 'type_2-obj_1', + 'type_dep-obj_1', + ]); + + expect(objects[0].attributes.enabled).to.eql(false); + expect(objects[1].attributes.enabled).to.eql(false); }); - }); + }); }); }); } diff --git a/tsconfig.base.json b/tsconfig.base.json index eca78e492ff5e3..cc8b66848a394c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + // Setup @kbn paths for Bazel compilations + "@kbn/*": [ + "node_modules/@kbn/*", + "bazel-out/darwin-fastbuild/bin/packages/kbn-*", + "bazel-out/k8-fastbuild/bin/packages/kbn-*", + "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*", + ], // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index c80541ee1ba6ba..e4f0b406076794 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -37,6 +37,18 @@ export function getEnvironmentLabel(environment: string) { return environmentLabels[environment] || environment; } +export function omitEsFieldValue({ + esFieldValue, + value, + text, +}: { + esFieldValue?: string; + value: string; + text: string; +}) { + return { value, text }; +} + export function parseEnvironmentUrlParam(environment: string) { if (environment === ENVIRONMENT_ALL_VALUE) { return ENVIRONMENT_ALL; diff --git a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature index 4c598d8d168a42..2b95216bc37196 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @@ -29,7 +29,6 @@ Feature: CSM Dashboard When a user browses the APM UI application for RUM Data Then should display percentile for page load chart And should display tooltip on hover - And should display chart legend Scenario: Breakdown filter Given a user clicks the page load breakdown filter diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 47154ee214dc42..cbcb48796a6d4b 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -51,16 +51,16 @@ Then(`should display percentile for page load chart`, () => { cy.get(pMarkers).eq(3).should('have.text', '95th'); }); -Then(`should display chart legend`, () => { - const chartLegend = 'button.echLegendItem__label'; +// Then(`should display chart legend`, () => { +// const chartLegend = 'button.echLegendItem__label'; - waitForLoadingToFinish(); - cy.get('.euiLoadingChart').should('not.exist'); +// waitForLoadingToFinish(); +// cy.get('.euiLoadingChart').should('not.exist'); - cy.get('[data-cy=pageLoadDist]').within(() => { - cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); - }); -}); +// cy.get('[data-cy=pageLoadDist]').within(() => { +// cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); +// }); +// }); Then(`should display tooltip on hover`, () => { cy.get('.euiLoadingChart').should('not.exist'); diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 4ec654a6c0bfda..57285649677dcb 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; @@ -15,6 +16,7 @@ import { renderApp } from './'; import { disableConsoleWarning } from '../utils/testHelpers'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ApmPluginStartDeps } from '../plugin'; jest.mock('../services/rest/index_pattern', () => ({ createStaticIndexPattern: () => Promise.resolve(undefined), @@ -44,6 +46,7 @@ describe('renderApp', () => { config, observabilityRuleTypeRegistry, } = mockApmPluginContextValue; + const plugins = { licensing: { license$: new Observable() }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {} }, @@ -56,7 +59,7 @@ describe('renderApp', () => { }, }, }; - const params = { + const appMountParameters = { element: document.createElement('div'), history: createMemoryHistory(), setHeaderActionMenu: () => {}, @@ -64,7 +67,16 @@ describe('renderApp', () => { const data = dataPluginMock.createStartContract(); const embeddable = embeddablePluginMock.createStartContract(); - const startDeps = { + + const pluginsStart = ({ + observability: { + navigation: { + registerSections: () => jest.fn(), + PageTemplate: ({ children }: { children: React.ReactNode }) => ( +
hello worlds {children}
+ ), + }, + }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {}, @@ -73,7 +85,8 @@ describe('renderApp', () => { }, data, embeddable, - }; + } as unknown) as ApmPluginStartDeps; + jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); createCallApmApi((core as unknown) as CoreStart); @@ -93,8 +106,8 @@ describe('renderApp', () => { unmount = renderApp({ coreStart: core as any, pluginsSetup: plugins as any, - appMountParameters: params as any, - pluginsStart: startDeps as any, + appMountParameters: appMountParameters as any, + pluginsStart, config, observabilityRuleTypeRegistry, }); diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 11a2777f47f6a6..ca4f4856894f9e 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -20,7 +20,6 @@ import { useUiSetting$, } from '../../../../../src/plugins/kibana_react/public'; import { APMRouteDefinition } from '../application/routes'; -import { renderAsRedirectTo } from '../components/app/Main/route_config'; import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; import { RumHome, UX_LABEL } from '../components/app/RumDashboard/RumHome'; import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; @@ -32,6 +31,7 @@ import { createCallApmApi } from '../services/rest/createCallApmApi'; import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { UXActionMenu } from '../components/app/RumDashboard/ActionMenu'; +import { redirectTo } from '../components/routing/redirect_to'; const CsmMainContainer = euiStyled.div` padding: ${px(units.plus)}; @@ -42,7 +42,7 @@ export const rumRoutes: APMRouteDefinition[] = [ { exact: true, path: '/', - render: renderAsRedirectTo('/ux'), + render: redirectTo('/ux'), breadcrumb: UX_LABEL, }, ]; diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index e2a0bdb6b48b13..9b8d3c7822d3d9 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -5,99 +5,18 @@ * 2.0. */ -import { ApmRoute } from '@elastic/apm-rum-react'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Route, Router, Switch } from 'react-router-dom'; import 'react-vis/dist/style.css'; -import { DefaultTheme, ThemeProvider } from 'styled-components'; import type { ObservabilityRuleTypeRegistry } from '../../../observability/public'; -import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; import { ConfigSchema } from '../'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; -import { - KibanaContextProvider, - RedirectAppLinks, - useUiSetting$, -} from '../../../../../src/plugins/kibana_react/public'; -import { routes } from '../components/app/Main/route_config'; -import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; -import { - ApmPluginContext, - ApmPluginContextValue, -} from '../context/apm_plugin/apm_plugin_context'; -import { LicenseProvider } from '../context/license/license_context'; -import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { setHelpExtension } from '../setHelpExtension'; import { setReadonlyBadge } from '../updateBadge'; -import { AnomalyDetectionJobsContextProvider } from '../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; - -const MainContainer = euiStyled.div` - height: 100%; -`; - -function App() { - const [darkMode] = useUiSetting$('theme:darkMode'); - - useBreadcrumbs(routes); - - return ( - ({ - ...outerTheme, - eui: darkMode ? euiDarkVars : euiLightVars, - darkMode, - })} - > - - - - {routes.map((route, i) => ( - - ))} - - - - ); -} - -export function ApmAppRoot({ - apmPluginContextValue, - startDeps, -}: { - apmPluginContextValue: ApmPluginContextValue; - startDeps: ApmPluginStartDeps; -}) { - const { appMountParameters, core } = apmPluginContextValue; - const { history } = appMountParameters; - const i18nCore = core.i18n; - - return ( - - - - - - - - - - - - - - - - - - ); -} +import { ApmAppRoot } from '../components/routing/app_root'; /** * This module is rendered asynchronously in the Kibana platform. @@ -141,7 +60,7 @@ export const renderApp = ({ ReactDOM.render( , element ); diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 50788c28999b53..35863d80993944 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -10,24 +10,17 @@ import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertType } from '../../../../common/alert_types'; import { getInitialAlertValues } from '../get_initial_alert_values'; -import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; +import { ApmPluginStartDeps } from '../../../plugin'; interface Props { addFlyoutVisible: boolean; setAddFlyoutVisibility: React.Dispatch>; alertType: AlertType | null; } -interface KibanaDeps { - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; -} - export function AlertingFlyout(props: Props) { const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; const { serviceName } = useParams<{ serviceName?: string }>(); - const { - services: { triggersActionsUi }, - } = useKibana(); - + const { services } = useKibana(); const initialValues = getInitialAlertValues(alertType, serviceName); const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ @@ -37,7 +30,7 @@ export function AlertingFlyout(props: Props) { const addAlertFlyout = useMemo( () => alertType && - triggersActionsUi.getAddAlertFlyout({ + services.triggersActionsUi.getAddAlertFlyout({ consumer: 'apm', onClose: onCloseAddFlyout, alertTypeId: alertType, @@ -45,7 +38,7 @@ export function AlertingFlyout(props: Props) { initialValues, }), /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [alertType, onCloseAddFlyout, triggersActionsUi] + [alertType, onCloseAddFlyout, services.triggersActionsUi] ); return <>{addFlyoutVisible && addAlertFlyout}; } diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx index 5671f0bfcf085a..9cb5a57b090f3e 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx @@ -6,7 +6,6 @@ */ import { shallow } from 'enzyme'; -import { Location } from 'history'; import React from 'react'; import { mockMoment } from '../../../../utils/testHelpers'; import { DetailView } from './index'; @@ -19,11 +18,7 @@ describe('DetailView', () => { it('should render empty state', () => { const wrapper = shallow( - + ); expect(wrapper.isEmptyRender()).toBe(true); }); @@ -46,11 +41,7 @@ describe('DetailView', () => { }; const wrapper = shallow( - + ).find('DiscoverErrorLink'); expect(wrapper.exists()).toBe(true); @@ -69,11 +60,7 @@ describe('DetailView', () => { transaction: undefined, }; const wrapper = shallow( - + ).find('Summary'); expect(wrapper.exists()).toBe(true); @@ -93,11 +80,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('EuiTabs'); expect(wrapper.exists()).toBe(true); @@ -117,11 +100,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('TabContent'); expect(wrapper.exists()).toBe(true); @@ -145,13 +124,7 @@ describe('DetailView', () => { } as any, }; expect(() => - shallow( - - ) + shallow() ).not.toThrowError(); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx index cd893c17369889..da55f274bd77ce 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -16,7 +16,6 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import { first } from 'lodash'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -58,7 +57,6 @@ const TransactionLinkName = euiStyled.div` interface Props { errorGroup: APIReturnType<'GET /api/apm/services/{serviceName}/errors/{groupId}'>; urlParams: IUrlParams; - location: Location; } // TODO: Move query-string-based tabs into a re-usable component? @@ -70,7 +68,7 @@ function getCurrentTab( return selectedTab ? selectedTab : first(tabs) || {}; } -export function DetailView({ errorGroup, urlParams, location }: Props) { +export function DetailView({ errorGroup, urlParams }: Props) { const history = useHistory(); const { transaction, error, occurrencesCount } = errorGroup; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 5fcd2914f2225c..0f2180721afe3b 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -9,24 +9,19 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiPage, - EuiPageBody, EuiPanel, EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { SearchBar } from '../../shared/search_bar'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; @@ -68,44 +63,42 @@ function ErrorGroupHeader({ isUnhandled?: boolean; }) { return ( - <> - - - - -

- {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { - defaultMessage: 'Error group {errorGroupId}', - values: { - errorGroupId: getShortGroupId(groupId), - }, - })} -

-
-
- {isUnhandled && ( - - - {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { - defaultMessage: 'Unhandled', - })} - - - )} -
-
- - + + + +

+ {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { + defaultMessage: 'Error group {errorGroupId}', + values: { + errorGroupId: getShortGroupId(groupId), + }, + })} +

+
+
+ + {isUnhandled && ( + + + {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { + defaultMessage: 'Unhandled', + })} + + + )} +
); } -type ErrorGroupDetailsProps = RouteComponentProps<{ +interface ErrorGroupDetailsProps { groupId: string; serviceName: string; -}>; +} -export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { - const { serviceName, groupId } = match.params; +export function ErrorGroupDetails({ + serviceName, + groupId, +}: ErrorGroupDetailsProps) { const { urlParams } = useUrlParams(); const { environment, kuery, start, end } = urlParams; const { data: errorGroupData } = useFetcher( @@ -154,66 +147,56 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { return ( <> - - - - {showDetails && ( - - - {logMessage && ( - - - {logMessage} - - )} - - {excMessage || NOT_AVAILABLE_LABEL} + + + {showDetails && ( + + + {logMessage && ( + <> - {culprit || NOT_AVAILABLE_LABEL} - - - )} - {logMessage} + )} - /> - - - {showDetails && ( - + + {excMessage || NOT_AVAILABLE_LABEL} + + {culprit || NOT_AVAILABLE_LABEL} + + + )} + - + /> + + + {showDetails && ( + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx deleted file mode 100644 index ab3b76848c248f..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; -import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; - -describe('Home component', () => { - it('should render services', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); - - it('should render traces', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap deleted file mode 100644 index f13cce3fd9b40f..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ /dev/null @@ -1,189 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Home component should render services 1`] = ` - - - -`; - -exports[`Home component should render traces 1`] = ` - - - -`; diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx deleted file mode 100644 index 834c2d5c40bcec..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTab, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ComponentType } from 'react'; -import { $ElementType } from 'utility-types'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceInventoryHref } from '../../shared/Links/apm/service_inventory_link'; -import { useTraceOverviewHref } from '../../shared/Links/apm/TraceOverviewLink'; -import { MainTabs } from '../../shared/main_tabs'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceInventory } from '../service_inventory'; -import { TraceOverview } from '../trace_overview'; - -interface Tab { - key: string; - href: string; - text: string; - Component: ComponentType; -} - -interface Props { - tab: 'traces' | 'services' | 'service-map'; -} - -export function Home({ tab }: Props) { - const homeTabs: Tab[] = [ - { - key: 'services', - href: useServiceInventoryHref(), - text: i18n.translate('xpack.apm.home.servicesTabLabel', { - defaultMessage: 'Services', - }), - Component: ServiceInventory, - }, - { - key: 'traces', - href: useTraceOverviewHref(), - text: i18n.translate('xpack.apm.home.tracesTabLabel', { - defaultMessage: 'Traces', - }), - Component: TraceOverview, - }, - { - key: 'service-map', - href: useServiceMapHref(), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - Component: ServiceMap, - }, - ]; - const selectedTab = homeTabs.find( - (homeTab) => homeTab.key === tab - ) as $ElementType; - - return ( - <> - - -

APM

-
-
- - {homeTabs.map(({ href, key, text }) => ( - - {text} - - ))} - - - - ); -} 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 deleted file mode 100644 index 89b8db5f386dcd..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; -import { getServiceNodeName } from '../../../../../common/service_nodes'; -import { APMRouteDefinition } from '../../../../application/routes'; -import { toQuery } from '../../../shared/Links/url_helpers'; -import { ErrorGroupDetails } from '../../ErrorGroupDetails'; -import { Home } from '../../Home'; -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'; -import { ApmIndices } from '../../Settings/ApmIndices'; -import { CustomizeUI } from '../../Settings/CustomizeUI'; -import { TraceLink } from '../../TraceLink'; -import { TransactionDetails } from '../../transaction_details'; -import { - CreateAgentConfigurationRouteHandler, - EditAgentConfigurationRouteHandler, -} from './route_handlers/agent_configuration'; -import { enableServiceOverview } from '../../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; - -/** - * Given a path, redirect to that location, preserving the search and maintaining - * backward-compatibilty with legacy (pre-7.9) hash-based URLs. - */ -export function renderAsRedirectTo(to: string) { - return ({ location }: RouteComponentProps<{}>) => { - let resolvedUrl: URL | undefined; - - // Redirect root URLs with a hash to support backward compatibility with URLs - // from before we switched to the non-hash platform history. - if (location.pathname === '' && location.hash.length > 0) { - // We just want the search and pathname so the host doesn't matter - resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); - to = resolvedUrl.pathname; - } - - return ( - - ); - }; -} - -// These component function definitions are used below with the `component` -// property of the route definitions. -// -// If you provide an inline function to the component prop, you would create a -// new component every render. This results in the existing component unmounting -// and the new component mounting instead of just updating the existing component. -function HomeServices() { - return ; -} - -function HomeServiceMap() { - return ; -} - -function HomeTraces() { - return ; -} - -function ServiceDetailsErrors( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsMetrics( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsNodes( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsOverview( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsServiceMap( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsTransactions( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsProfiling( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsAnomalyDetection(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsApmIndices(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsCustomizeUI(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function DefaultServicePageRouteHandler( - props: RouteComponentProps<{ serviceName: string }> -) { - const { uiSettings } = useApmPluginContext().core; - const { serviceName } = props.match.params; - if (uiSettings.get(enableServiceOverview)) { - return renderAsRedirectTo(`/services/${serviceName}/overview`)(props); - } - return renderAsRedirectTo(`/services/${serviceName}/transactions`)(props); -} - -/** - * The array of route definitions to be used when the application - * creates the routes. - */ -export const routes: APMRouteDefinition[] = [ - { - exact: true, - path: '/', - render: renderAsRedirectTo('/services'), - breadcrumb: 'APM', - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/services', - component: HomeServices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { - defaultMessage: 'Services', - }), - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/traces', - component: HomeTraces, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { - defaultMessage: 'Traces', - }), - }, - { - exact: true, - path: '/settings', - render: renderAsRedirectTo('/settings/agent-configuration'), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', { - defaultMessage: 'Settings', - }), - }, - { - exact: true, - path: '/settings/apm-indices', - component: SettingsApmIndices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', { - defaultMessage: 'Indices', - }), - }, - { - exact: true, - path: '/settings/agent-configuration', - component: SettingsAgentConfiguration, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { defaultMessage: 'Agent Configuration' } - ), - }, - { - exact: true, - path: '/settings/agent-configuration/create', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', - { defaultMessage: 'Create Agent Configuration' } - ), - component: CreateAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/settings/agent-configuration/edit', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', - { defaultMessage: 'Edit Agent Configuration' } - ), - component: EditAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/services/:serviceName', - breadcrumb: ({ match }) => match.params.serviceName, - component: DefaultServicePageRouteHandler, - } as APMRouteDefinition<{ serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/overview', - breadcrumb: i18n.translate('xpack.apm.breadcrumb.overviewTitle', { - defaultMessage: 'Overview', - }), - component: withApmServiceContext(ServiceDetailsOverview), - } as APMRouteDefinition<{ serviceName: string }>, - // errors - { - exact: true, - path: '/services/:serviceName/errors/:groupId', - component: withApmServiceContext(ErrorGroupDetails), - breadcrumb: ({ match }) => match.params.groupId, - } as APMRouteDefinition<{ groupId: string; serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/errors', - component: withApmServiceContext(ServiceDetailsErrors), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { - defaultMessage: 'Errors', - }), - }, - // transactions - { - exact: true, - path: '/services/:serviceName/transactions', - component: withApmServiceContext(ServiceDetailsTransactions), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { - defaultMessage: 'Transactions', - }), - }, - // metrics - { - exact: true, - path: '/services/:serviceName/metrics', - component: withApmServiceContext(ServiceDetailsMetrics), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.metricsTitle', { - defaultMessage: 'Metrics', - }), - }, - // service nodes, only enabled for java agents for now - { - exact: true, - path: '/services/:serviceName/nodes', - component: withApmServiceContext(ServiceDetailsNodes), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', { - defaultMessage: 'JVMs', - }), - }, - // node metrics - { - exact: true, - path: '/services/:serviceName/nodes/:serviceNodeName/metrics', - component: withApmServiceContext(ServiceNodeMetrics), - breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), - }, - { - exact: true, - path: '/services/:serviceName/transactions/view', - component: withApmServiceContext(TransactionDetails), - breadcrumb: ({ location }) => { - const query = toQuery(location.search); - return query.transactionName as string; - }, - }, - { - exact: true, - path: '/services/:serviceName/profiling', - component: withApmServiceContext(ServiceDetailsProfiling), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { - defaultMessage: 'Profiling', - }), - }, - { - exact: true, - path: '/services/:serviceName/service-map', - component: withApmServiceContext(ServiceDetailsServiceMap), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/link-to/trace/:traceId', - component: TraceLink, - breadcrumb: null, - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/service-map', - component: HomeServiceMap, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/settings/customize-ui', - component: SettingsCustomizeUI, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', { - defaultMessage: 'Customize UI', - }), - }, - { - exact: true, - path: '/settings/anomaly-detection', - component: SettingsAnomalyDetection, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - }, -]; - -function withApmServiceContext(WrappedComponent: React.ComponentType) { - return (props: any) => { - return ( - - - - ); - }; -} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 3225951fd6c70c..b781a6569cc359 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -42,12 +42,12 @@ export function AgentConfigurations() { return ( <> - -

+ +

{i18n.translate('xpack.apm.agentConfig.titleText', { defaultMessage: 'Agent central configuration', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index 70672df85b649d..28cb4ebd51cddc 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -25,11 +25,11 @@ describe('ApmIndices', () => { ); expect(getByText('Indices')).toMatchInlineSnapshot(` -

Indices -

+ `); expect(spy).toHaveBeenCalledTimes(2); diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 9d2b4bba22afbe..44a3c4655417cf 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -176,12 +176,12 @@ export function ApmIndices() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.apmIndices.title', { defaultMessage: 'Indices', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index fabd70cec66475..c4b3c39248ffbf 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -13,12 +13,12 @@ import { CustomLinkOverview } from './CustomLink'; export function CustomizeUI() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.customizeApp.title', { defaultMessage: 'Customize app', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 62b39664cf63da..38b9970f64d32c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -66,12 +66,12 @@ export function AnomalyDetection() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.anomalyDetection.titleText', { defaultMessage: 'Anomaly detection', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 36c36e3957e962..b4cba2afc2550d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -5,32 +5,19 @@ * 2.0. */ -import { - EuiButtonEmpty, - EuiPage, - EuiPageBody, - EuiPageSideBar, - EuiSideNav, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; +import { useHistory } from 'react-router-dom'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; -import { HomeLink } from '../../shared/Links/apm/HomeLink'; -interface SettingsProps extends RouteComponentProps<{}> { - children: ReactNode; -} - -export function Settings({ children, location }: SettingsProps) { - const { appMountParameters, core } = useApmPluginContext(); +export function Settings({ children }: { children: ReactNode }) { + const { core } = useApmPluginContext(); + const history = useHistory(); const { basePath } = core.http; const canAccessML = !!core.application.capabilities.ml?.canAccessML; - const { search, pathname } = location; + const { search, pathname } = history.location; const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); @@ -43,86 +30,65 @@ export function Settings({ children, location }: SettingsProps) { } return ( - <> - - - - - - - - {i18n.translate('xpack.apm.settings.returnLinkLabel', { - defaultMessage: 'Return to inventory', - })} - - - - toggleOpenOnMobile()} - isOpenOnMobile={isSideNavOpenOnMobile} - items={[ - { - name: i18n.translate('xpack.apm.settings.pageTitle', { - defaultMessage: 'Settings', - }), - id: 0, - items: [ - { - name: i18n.translate('xpack.apm.settings.agentConfig', { - defaultMessage: 'Agent Configuration', - }), - id: '1', - href: getSettingsHref('/agent-configuration'), - isSelected: pathname.startsWith( - '/settings/agent-configuration' - ), - }, - ...(canAccessML - ? [ - { - name: i18n.translate( - 'xpack.apm.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - id: '4', - href: getSettingsHref('/anomaly-detection'), - isSelected: - pathname === '/settings/anomaly-detection', - }, - ] - : []), - { - name: i18n.translate('xpack.apm.settings.customizeApp', { - defaultMessage: 'Customize app', - }), - id: '3', - href: getSettingsHref('/customize-ui'), - isSelected: pathname === '/settings/customize-ui', - }, - { - name: i18n.translate('xpack.apm.settings.indices', { - defaultMessage: 'Indices', - }), - id: '2', - href: getSettingsHref('/apm-indices'), - isSelected: pathname === '/settings/apm-indices', - }, - ], - }, - ]} - /> - - {children} - - + + + toggleOpenOnMobile()} + isOpenOnMobile={isSideNavOpenOnMobile} + items={[ + { + name: i18n.translate('xpack.apm.settings.pageTitle', { + defaultMessage: 'Settings', + }), + id: 0, + items: [ + { + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration', + }), + id: '1', + href: getSettingsHref('/agent-configuration'), + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ), + }, + ...(canAccessML + ? [ + { + name: i18n.translate( + 'xpack.apm.settings.anomalyDetection', + { + defaultMessage: 'Anomaly detection', + } + ), + id: '4', + href: getSettingsHref('/anomaly-detection'), + isSelected: pathname === '/settings/anomaly-detection', + }, + ] + : []), + { + name: i18n.translate('xpack.apm.settings.customizeApp', { + defaultMessage: 'Customize app', + }), + id: '3', + href: getSettingsHref('/customize-ui'), + isSelected: pathname === '/settings/customize-ui', + }, + { + name: i18n.translate('xpack.apm.settings.indices', { + defaultMessage: 'Indices', + }), + id: '2', + href: getSettingsHref('/apm-indices'), + isSelected: pathname === '/settings/apm-indices', + }, + ], + }, + ]} + /> + + {children} + ); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 6f7a8228db298e..95ec80b1a51bc2 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, - EuiPage, + EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle, @@ -18,7 +18,6 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { SearchBar } from '../../shared/search_bar'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; @@ -68,41 +67,41 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); if (!errorDistributionData || !errorGroupListData) { - return ; + return null; } return ( - <> - - - - - - + + + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceDetails.metrics.errorsList.title', + { defaultMessage: 'Errors' } + )} +

+
- - -

Errors

-
- - - -
-
-
- + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_details/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/index.tsx deleted file mode 100644 index 29bb1c04ab9456..00000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { ServiceIcons } from './service_icons'; -import { ServiceDetailTabs } from './service_detail_tabs'; - -interface Props extends RouteComponentProps<{ serviceName: string }> { - tab: React.ComponentProps['tab']; -} - -export function ServiceDetails({ match, tab }: Props) { - const { serviceName } = match.params; - - return ( -
- - - - -

{serviceName}

-
-
- - - -
-
- -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx deleted file mode 100644 index d360b186aba169..00000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTab } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ReactNode } from 'react'; -import { EuiBetaBadge } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; -import { enableServiceOverview } from '../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; -import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; -import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; -import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; -import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; -import { MainTabs } from '../../shared/main_tabs'; -import { ErrorGroupOverview } from '../error_group_overview'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceNodeOverview } from '../service_node_overview'; -import { ServiceMetrics } from '../service_metrics'; -import { ServiceOverview } from '../service_overview'; -import { TransactionOverview } from '../transaction_overview'; -import { ServiceProfiling } from '../service_profiling'; -import { Correlations } from '../correlations'; - -interface Tab { - key: string; - href: string; - text: ReactNode; - hidden?: boolean; - render: () => ReactNode; -} - -interface Props { - serviceName: string; - tab: - | 'errors' - | 'metrics' - | 'nodes' - | 'overview' - | 'service-map' - | 'profiling' - | 'transactions'; -} - -export function ServiceDetailTabs({ serviceName, tab }: Props) { - const { agentName, transactionType } = useApmServiceContext(); - const { - core: { uiSettings }, - config, - } = useApmPluginContext(); - const { - urlParams: { latencyAggregationType }, - } = useUrlParams(); - - const overviewTab = { - key: 'overview', - href: useServiceOverviewHref({ serviceName, transactionType }), - text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { - defaultMessage: 'Overview', - }), - render: () => ( - - ), - }; - - const transactionsTab = { - key: 'transactions', - href: useTransactionsOverviewHref({ - serviceName, - latencyAggregationType, - transactionType, - }), - text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { - defaultMessage: 'Transactions', - }), - render: () => , - }; - - const errorsTab = { - key: 'errors', - href: useErrorOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { - defaultMessage: 'Errors', - }), - render: () => { - return ; - }, - }; - - const serviceMapTab = { - key: 'service-map', - href: useServiceMapHref(serviceName), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - render: () => , - }; - - const nodesListTab = { - key: 'nodes', - href: useServiceNodeOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { - defaultMessage: 'JVMs', - }), - render: () => , - }; - - const metricsTab = { - key: 'metrics', - href: useMetricOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { - defaultMessage: 'Metrics', - }), - render: () => - agentName ? ( - - ) : null, - }; - - const profilingTab = { - key: 'profiling', - href: useServiceProfilingHref({ serviceName }), - hidden: !config.profilingEnabled, - text: ( - - - {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { - defaultMessage: 'Profiling', - })} - - - - - - ), - render: () => , - }; - - const tabs: Tab[] = [transactionsTab, errorsTab]; - - if (uiSettings.get(enableServiceOverview)) { - tabs.unshift(overviewTab); - } - - if (isJavaAgentName(agentName)) { - tabs.push(nodesListTab); - } else if (agentName && !isRumAgentName(agentName)) { - tabs.push(metricsTab); - } - - tabs.push(serviceMapTab, profilingTab); - - const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); - - return ( - <> - - {tabs - .filter((t) => !t.hidden) - .map(({ href, key, text }) => ( - - {text} - - ))} -
- -
-
- {selectedTab ? selectedTab.render() : null} - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 9c4728488d96a1..78f02c5a667016 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPage, - EuiPanel, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; @@ -126,28 +120,26 @@ export function ServiceInventory() { return ( <> - - - {displayMlCallout ? ( - - setUserHasDismissedCallout(true)} /> - - ) : null} + + {displayMlCallout && ( - - - } - /> - + setUserHasDismissedCallout(true)} /> - - + )} + + + + } + /> + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx rename to x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index ac1846155569af..fe3922060533a9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -13,11 +13,11 @@ import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { CytoscapeContext } from '../Cytoscape'; -import { Popover } from './'; +import { Popover } from '.'; import exampleGroupedConnectionsData from '../__stories__/example_grouped_connections.json'; export default { - title: 'app/ServiceMap/Popover', + title: 'app/service_map/Popover', component: Popover, decorators: [ (Story: ComponentType) => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx index a8f004a7295d90..83f0a3ea7e4b9e 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx @@ -10,7 +10,7 @@ import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_rea import { ServiceStatsList } from './ServiceStatsList'; export default { - title: 'app/ServiceMap/Popover/ServiceStatsList', + title: 'app/service_map/Popover/ServiceStatsList', component: ServiceStatsList, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx index 37644c084815e9..c3f3c09e10e4f8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx @@ -14,7 +14,7 @@ import { iconForNode } from '../icons'; import { Centerer } from './centerer'; export default { - title: 'app/ServiceMap/Cytoscape', + title: 'app/service_map/Cytoscape', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx index 41eecb9181a2c5..84351d5716edb2 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx @@ -25,7 +25,7 @@ import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; import exampleResponseTodo from './example_response_todo.json'; import { generateServiceMapElements } from './generate_service_map_elements'; -const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; +const STORYBOOK_PATH = 'app/service_map/Cytoscape/Example data'; const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`; function getSessionJson() { @@ -40,7 +40,7 @@ function getHeight() { } export default { - title: 'app/ServiceMap/Cytoscape/Example data', + title: 'app/service_map/Cytoscape/Example data', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts b/x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts rename to x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/service_map/icons.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts rename to x-pack/plugins/apm/public/components/app/service_map/icons.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.test.tsx index e8384de1d15bae..f68d8e46f66e3a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx @@ -15,7 +15,7 @@ import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/ import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../context/license/license_context'; import * as useFetcherModule from '../../../hooks/use_fetcher'; -import { ServiceMap } from './'; +import { ServiceMap } from '.'; import { UrlParamsProvider } from '../../../context/url_params_context/url_params_context'; import { Router } from 'react-router-dom'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx similarity index 87% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.tsx index b338d1e4ab03dc..714228d58f9620 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -7,7 +7,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React, { PropsWithChildren, ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { isActivePlatinumLicense } from '../../../../common/license_check'; import { useTrackPageview } from '../../../../../observability/public'; import { @@ -18,7 +17,6 @@ import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useLicenseContext } from '../../../context/license/use_license_context'; import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { DatePicker } from '../../shared/DatePicker'; import { LicensePrompt } from '../../shared/license_prompt'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; @@ -28,31 +26,16 @@ import { EmptyPrompt } from './empty_prompt'; import { Popover } from './Popover'; import { TimeoutPrompt } from './timeout_prompt'; import { useRefDimensions } from './useRefDimensions'; +import { SearchBar } from '../../shared/search_bar'; interface ServiceMapProps { serviceName?: string; } -const ServiceMapDatePickerFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.euiSizeM}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - margin: 0; -`; - -function DatePickerSection() { - return ( - - - - - - ); -} - function PromptContainer({ children }: { children: ReactNode }) { return ( <> - + - + +
- - - - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - - - + + + {data.charts.map((chart) => ( + + + + + + ))} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx index d9cd003042a45a..8711366fdd1859 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx @@ -9,20 +9,17 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ServiceNodeMetrics } from '.'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { RouteComponentProps } from 'react-router-dom'; describe('ServiceNodeMetrics', () => { describe('render', () => { it('renders', () => { - const props = ({} as unknown) as RouteComponentProps<{ - serviceName: string; - serviceNodeName: string; - }>; - expect(() => shallow( - + ) ).not.toThrowError(); diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 186c148fa69187..20b78b90e03781 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -10,17 +10,14 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiPage, EuiPanel, EuiSpacer, EuiStat, - EuiTitle, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; @@ -29,10 +26,8 @@ import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { px, truncate, unit } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; import { MetricsChart } from '../../shared/charts/metrics_chart'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_DATA = { host: '', @@ -51,16 +46,18 @@ const MetadataFlexGroup = euiStyled(EuiFlexGroup)` `${theme.eui.paddingSizes.m} 0 0 ${theme.eui.paddingSizes.m}`}; `; -type ServiceNodeMetricsProps = RouteComponentProps<{ +interface ServiceNodeMetricsProps { serviceName: string; serviceNodeName: string; -}>; +} -export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { +export function ServiceNodeMetrics({ + serviceName, + serviceNodeName, +}: ServiceNodeMetricsProps) { const { urlParams: { kuery, start, end }, } = useUrlParams(); - const { serviceName, serviceNodeName } = match.params; const { agentName } = useApmServiceContext(); const { data } = useServiceMetricChartsFetcher({ serviceNodeName }); @@ -89,15 +86,6 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { return ( <> - - - - -

{serviceName}

-
-
-
-
{isAggregatedData ? ( )} - - - {agentName && ( - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - )} - + + {agentName && ( + + + {data.charts.map((chart) => ( + + + + + + ))} + + + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 3d284de621ea38..69e5ea5a78ea12 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; +import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; @@ -22,7 +22,6 @@ import { useFetcher } from '../../../hooks/use_fetcher'; import { px, truncate, unit } from '../../../style/variables'; import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { ITableColumn, ManagedTable } from '../../shared/ManagedTable'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_PAGE_SIZE = 25; const INITIAL_SORT_FIELD = 'cpu'; @@ -143,28 +142,18 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { ]; return ( - <> - - - - - - - - - + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index cd1ced1830123f..f7046d9e401381 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -5,17 +5,17 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakPoints } from '../../../hooks/use_break_points'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -29,14 +29,12 @@ import { ServiceOverviewTransactionsTable } from './service_overview_transaction export const chartHeight = 288; interface ServiceOverviewProps { - agentName?: string; serviceName: string; } -export function ServiceOverview({ - agentName, - serviceName, -}: ServiceOverviewProps) { +export function ServiceOverview({ serviceName }: ServiceOverviewProps) { + const { agentName } = useApmServiceContext(); + useTrackPageview({ app: 'apm', path: 'service_overview' }); useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); @@ -49,89 +47,84 @@ export function ServiceOverview({ return ( - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {!isRumAgent && ( - + + )} + + + + + + + + + + + + + {!isRumAgent && ( - - - + )} + + + {!isRumAgent && ( - {!isRumAgent && ( - - - - )} - - - - - + - - - - - - {!isRumAgent && ( - - - - - - )} - - - {!isRumAgent && ( - - - - - - )} - - + )} + ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index 46747e18c44afd..a92efff1039104 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -234,6 +234,7 @@ export function getColumns({ anchorPosition="leftCenter" button={ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index ba1da7e6dd6eb9..5c2bbd9e20c59e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -32,10 +32,7 @@ import { pct } from '../../../../style/variables'; import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; import { KeyValueFilterList } from '../../../shared/key_value_filter_list'; import { pushNewItemToKueryBar } from '../../../shared/KueryBar/utils'; -import { - getCloudIcon, - getContainerIcon, -} from '../../service_details/service_icons'; +import { getCloudIcon, getContainerIcon } from '../../../shared/service_icons'; import { useInstanceDetailsFetcher } from './use_instance_details_fetcher'; type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx index 94391b5b2fb069..c6e1f575298c68 100644 --- a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -4,14 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import { getValueTypeConfig, @@ -20,7 +13,6 @@ import { import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; import { ServiceProfilingTimeline } from './service_profiling_timeline'; @@ -90,54 +82,38 @@ export function ServiceProfiling({ return ( <> - - - - - -

- {i18n.translate('xpack.apm.profilingOverviewTitle', { - defaultMessage: 'Profiling', - })} -

-
+ + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} - - - - { - setValueType(type); - }} - selectedValueType={valueType} - /> - - {valueType ? ( - - -

{getValueTypeConfig(valueType).label}

-
-
- ) : null} - - - -
-
+
-
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 364266d277482a..0938456193dc0d 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -50,16 +50,13 @@ export function TraceOverview() { return ( <> - - - - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx index 7f8ffb62d9e728..ae58e6f60cf09c 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx @@ -7,7 +7,6 @@ import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { LogStream } from '../../../../../../infra/public'; @@ -19,7 +18,6 @@ import { WaterfallContainer } from './WaterfallContainer'; import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; interface Props { - location: Location; transaction: Transaction; urlParams: IUrlParams; waterfall: IWaterfall; @@ -27,7 +25,6 @@ interface Props { } export function TransactionTabs({ - location, transaction, urlParams, waterfall, @@ -47,9 +44,9 @@ export function TransactionTabs({ { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); @@ -66,7 +63,6 @@ export function TransactionTabs({ ; urlParams: IUrlParams; waterfall: IWaterfall; exceedsMax: boolean; }) { return ( void; + toggleFlyout: ({ history }: { history: History }) => void; } export function WaterfallFlyout({ waterfallItemId, waterfall, - location, toggleFlyout, }: Props) { const history = useHistory(); @@ -52,14 +44,14 @@ export function WaterfallFlyout({ totalDuration={waterfall.duration} span={currentItem.doc} parentTransaction={parentTransaction} - onClose={() => toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} /> ); case 'transaction': return ( toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} rootTransactionDuration={ waterfall.rootTransaction?.transaction.duration.us } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx index baced34ad3e56f..b0721791081fac 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx @@ -6,7 +6,6 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import { Location } from 'history'; import { isEmpty } from 'lodash'; import React, { useState } from 'react'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -23,7 +22,6 @@ interface AccordionWaterfallProps { level: number; duration: IWaterfall['duration']; waterfallItemId?: string; - location: Location; errorsPerTransaction: IWaterfall['errorsPerTransaction']; childrenByParentId: Record; onToggleEntryTransaction?: () => void; @@ -100,7 +98,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { duration, childrenByParentId, waterfallItemId, - location, errorsPerTransaction, timelineMargins, onClickWaterfallItem, @@ -160,7 +157,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { item={child} level={nextLevel} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index 4be595ac16c6cc..d7613699221b48 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -7,7 +7,7 @@ import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { History, Location } from 'history'; +import { History } from 'history'; import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -40,14 +40,12 @@ const TIMELINE_MARGINS = { const toggleFlyout = ({ history, item, - location, }: { history: History; item?: IWaterfallItem; - location: Location; }) => { history.replace({ - ...location, + ...history.location, search: fromQuery({ ...toQuery(location.search), flyoutDetailTab: undefined, @@ -63,15 +61,9 @@ const WaterfallItemsContainer = euiStyled.div` interface Props { waterfallItemId?: string; waterfall: IWaterfall; - location: Location; exceedsMax: boolean; } -export function Waterfall({ - waterfall, - exceedsMax, - waterfallItemId, - location, -}: Props) { +export function Waterfall({ waterfall, exceedsMax, waterfallItemId }: Props) { const history = useHistory(); const [isAccordionOpen, setIsAccordionOpen] = useState(true); const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found @@ -97,13 +89,12 @@ export function Waterfall({ item={entryWaterfallTransaction} level={0} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={waterfall.errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} timelineMargins={TIMELINE_MARGINS} onClickWaterfallItem={(item: IWaterfallItem) => - toggleFlyout({ history, item, location }) + toggleFlyout({ history, item }) } onToggleEntryTransaction={() => setIsAccordionOpen((isOpen) => !isOpen)} /> @@ -148,7 +139,6 @@ export function Waterfall({ diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index 57743590ea5666..5ea2fca2dfa321 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -15,7 +15,6 @@ import { WaterfallContainer } from './index'; import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { inferredSpans, - location, simpleTrace, traceChildStartBeforeParent, traceWithErrors, @@ -45,7 +44,6 @@ export function Example() { ); return ( ; - -export function TransactionDetails({ - location, - match, -}: TransactionDetailsProps) { +export function TransactionDetails() { const { urlParams } = useUrlParams(); const history = useHistory(); const { @@ -90,48 +76,43 @@ export function TransactionDetails({ return ( <> - - -

{transactionName}

-
-
- - - - - - - - - - - { - if (!isEmpty(bucket.samples)) { - selectSampleFromBucketClick(bucket.samples[0]); - } - }} - /> - - - - - - - - - + +

{transactionName}

+
+ + + + + + + + + + + { + if (!isEmpty(bucket.samples)) { + selectSampleFromBucketClick(bucket.samples[0]); + } + }} + /> + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 9e2743d7b59862..38066b4ecd3f79 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -8,8 +8,6 @@ import { EuiCallOut, EuiCode, - EuiFlexGroup, - EuiPage, EuiPanel, EuiSpacer, EuiTitle, @@ -26,7 +24,6 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { SearchBar } from '../../shared/search_bar'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; import { useTransactionListFetcher } from './use_transaction_list'; @@ -80,62 +77,55 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - - - - - - - -

Transactions

-
- - {!transactionListData.isAggregationAccurate && ( - -

- - xpack.apm.ui.transactionGroupBucketSize - - ), - }} - /> - - - {i18n.translate( - 'xpack.apm.transactionCardinalityWarning.docsLink', - { defaultMessage: 'Learn more in the docs' } - )} - -

-
+ + + + +

Transactions

+
+ + {!transactionListData.isAggregationAccurate && ( + - -
-
-
+ color="danger" + iconType="alert" + > +

+ xpack.apm.ui.transactionGroupBucketSize + ), + }} + /> + + + {i18n.translate( + 'xpack.apm.transactionCardinalityWarning.docsLink', + { defaultMessage: 'Learn more in the docs' } + )} + +

+
+ )} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e4fbd075660605..9c4c2aa11a8582 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, getByText, queryByLabelText } from '@testing-library/react'; +import { queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; @@ -107,46 +107,6 @@ describe('TransactionOverview', () => { const FILTER_BY_TYPE_LABEL = 'Transaction type'; - describe('when transactionType is selected and multiple transaction types are given', () => { - it('renders a radio group with transaction types', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - expect(getByText(container, 'firstType')).not.toBeNull(); - }); - - it('should update the URL when a transaction type is selected', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' - ); - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - fireEvent.change(getByText(container, 'firstType').parentElement!, { - target: { value: 'firstType' }, - }); - - expect(history.push).toHaveBeenCalled(); - expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' - ); - }); - }); - describe('when a transaction type is selected, and there are no other transaction types', () => { it('does not render a radio group with transaction types', () => { const { container } = setup({ diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx new file mode 100644 index 00000000000000..af62f4f235af7f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -0,0 +1,478 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { getServiceNodeName } from '../../../common/service_nodes'; +import { APMRouteDefinition } from '../../application/routes'; +import { toQuery } from '../shared/Links/url_helpers'; +import { ErrorGroupDetails } from '../app/ErrorGroupDetails'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { ServiceNodeMetrics } from '../app/service_node_metrics'; +import { Settings } from '../app/Settings'; +import { AgentConfigurations } from '../app/Settings/AgentConfigurations'; +import { AnomalyDetection } from '../app/Settings/anomaly_detection'; +import { ApmIndices } from '../app/Settings/ApmIndices'; +import { CustomizeUI } from '../app/Settings/CustomizeUI'; +import { TraceLink } from '../app/TraceLink'; +import { TransactionDetails } from '../app/transaction_details'; +import { + CreateAgentConfigurationRouteHandler, + EditAgentConfigurationRouteHandler, +} from './route_handlers/agent_configuration'; +import { enableServiceOverview } from '../../../common/ui_settings_keys'; +import { redirectTo } from './redirect_to'; +import { ApmMainTemplate } from './templates/apm_main_template'; +import { ApmServiceTemplate } from './templates/apm_service_template'; +import { ServiceProfiling } from '../app/service_profiling'; +import { ErrorGroupOverview } from '../app/error_group_overview'; +import { ServiceMap } from '../app/service_map'; +import { ServiceNodeOverview } from '../app/service_node_overview'; +import { ServiceMetrics } from '../app/service_metrics'; +import { ServiceOverview } from '../app/service_overview'; +import { TransactionOverview } from '../app/transaction_overview'; +import { ServiceInventory } from '../app/service_inventory'; +import { TraceOverview } from '../app/trace_overview'; + +// These component function definitions are used below with the `component` +// property of the route definitions. +// +// If you provide an inline function to the component prop, you would create a +// new component every render. This results in the existing component unmounting +// and the new component mounting instead of just updating the existing component. + +const ServiceInventoryTitle = i18n.translate( + 'xpack.apm.views.serviceInventory.title', + { defaultMessage: 'Services' } +); + +function ServiceInventoryView() { + return ( + + + + ); +} + +const TraceOverviewTitle = i18n.translate( + 'xpack.apm.views.traceOverview.title', + { + defaultMessage: 'Traces', + } +); + +function TraceOverviewView() { + return ( + + + + ); +} + +const ServiceMapTitle = i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', +}); + +function ServiceMapView() { + return ( + + + + ); +} + +function ServiceDetailsErrorsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ErrorGroupDetailsRouteView( + props: RouteComponentProps<{ serviceName: string; groupId: string }> +) { + const { serviceName, groupId } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsMetricsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsNodesRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsOverviewRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsServiceMapRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsTransactionsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsProfilingRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceNodeMetricsRouteView( + props: RouteComponentProps<{ + serviceName: string; + serviceNodeName: string; + }> +) { + const { serviceName, serviceNodeName } = props.match.params; + return ( + + + + ); +} + +function TransactionDetailsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function SettingsAgentConfigurationRouteView() { + return ( + + + + + + ); +} + +function SettingsAnomalyDetectionRouteView() { + return ( + + + + + + ); +} + +function SettingsApmIndicesRouteView() { + return ( + + + + + + ); +} + +function SettingsCustomizeUI() { + return ( + + + + + + ); +} + +const SettingsApmIndicesTitle = i18n.translate( + 'xpack.apm.views.settings.indices.title', + { defaultMessage: 'Indices' } +); + +const SettingsAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.agentConfiguration.title', + { defaultMessage: 'Agent Configuration' } +); +const CreateAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.createAgentConfiguration.title', + { defaultMessage: 'Create Agent Configuration' } +); +const EditAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.editAgentConfiguration.title', + { defaultMessage: 'Edit Agent Configuration' } +); +const SettingsCustomizeUITitle = i18n.translate( + 'xpack.apm.views.settings.customizeUI.title', + { defaultMessage: 'Customize app' } +); +const SettingsAnomalyDetectionTitle = i18n.translate( + 'xpack.apm.views.settings.anomalyDetection.title', + { defaultMessage: 'Anomaly detection' } +); +const SettingsTitle = i18n.translate('xpack.apm.views.listSettings.title', { + defaultMessage: 'Settings', +}); + +/** + * The array of route definitions to be used when the application + * creates the routes. + */ +export const apmRouteConfig: APMRouteDefinition[] = [ + /* + * Home routes + */ + { + exact: true, + path: '/', + render: redirectTo('/services'), + breadcrumb: 'APM', + }, + { + exact: true, + path: '/services', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceInventoryView, + breadcrumb: ServiceInventoryTitle, + }, + { + exact: true, + path: '/traces', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: TraceOverviewView, + breadcrumb: TraceOverviewTitle, + }, + { + exact: true, + path: '/service-map', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceMapView, + breadcrumb: ServiceMapTitle, + }, + + /* + * Settings routes + */ + { + exact: true, + path: '/settings', + render: redirectTo('/settings/agent-configuration'), + breadcrumb: SettingsTitle, + }, + { + exact: true, + path: '/settings/agent-configuration', + component: SettingsAgentConfigurationRouteView, + breadcrumb: SettingsAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/create', + component: CreateAgentConfigurationRouteHandler, + breadcrumb: CreateAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: EditAgentConfigurationTitle, + component: EditAgentConfigurationRouteHandler, + }, + { + exact: true, + path: '/settings/apm-indices', + component: SettingsApmIndicesRouteView, + breadcrumb: SettingsApmIndicesTitle, + }, + { + exact: true, + path: '/settings/customize-ui', + component: SettingsCustomizeUI, + breadcrumb: SettingsCustomizeUITitle, + }, + { + exact: true, + path: '/settings/anomaly-detection', + component: SettingsAnomalyDetectionRouteView, + breadcrumb: SettingsAnomalyDetectionTitle, + }, + + /* + * Services routes (with APM Service context) + */ + { + exact: true, + path: '/services/:serviceName', + breadcrumb: ({ match }) => match.params.serviceName, + component: RedirectToDefaultServiceRouteView, + }, + { + exact: true, + path: '/services/:serviceName/overview', + breadcrumb: i18n.translate('xpack.apm.views.overview.title', { + defaultMessage: 'Overview', + }), + component: ServiceDetailsOverviewRouteView, + }, + { + exact: true, + path: '/services/:serviceName/transactions', + component: ServiceDetailsTransactionsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.transactions.title', { + defaultMessage: 'Transactions', + }), + }, + { + exact: true, + path: '/services/:serviceName/errors/:groupId', + component: ErrorGroupDetailsRouteView, + breadcrumb: ({ match }) => match.params.groupId, + }, + { + exact: true, + path: '/services/:serviceName/errors', + component: ServiceDetailsErrorsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.errors.title', { + defaultMessage: 'Errors', + }), + }, + { + exact: true, + path: '/services/:serviceName/metrics', + component: ServiceDetailsMetricsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.metrics.title', { + defaultMessage: 'Metrics', + }), + }, + // service nodes, only enabled for java agents for now + { + exact: true, + path: '/services/:serviceName/nodes', + component: ServiceDetailsNodesRouteView, + breadcrumb: i18n.translate('xpack.apm.views.nodes.title', { + defaultMessage: 'JVMs', + }), + }, + // node metrics + { + exact: true, + path: '/services/:serviceName/nodes/:serviceNodeName/metrics', + component: ServiceNodeMetricsRouteView, + breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), + }, + { + exact: true, + path: '/services/:serviceName/transactions/view', + component: TransactionDetailsRouteView, + breadcrumb: ({ location }) => { + const query = toQuery(location.search); + return query.transactionName as string; + }, + }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: ServiceDetailsProfilingRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceProfiling.title', { + defaultMessage: 'Profiling', + }), + }, + { + exact: true, + path: '/services/:serviceName/service-map', + component: ServiceDetailsServiceMapRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', + }), + }, + /* + * Utilility routes + */ + { + exact: true, + path: '/link-to/trace/:traceId', + component: TraceLink, + breadcrumb: null, + }, +]; + +function RedirectToDefaultServiceRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { uiSettings } = useApmPluginContext().core; + const { serviceName } = props.match.params; + if (uiSettings.get(enableServiceOverview)) { + return redirectTo(`/services/${serviceName}/overview`)(props); + } + return redirectTo(`/services/${serviceName}/transactions`)(props); +} diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx new file mode 100644 index 00000000000000..9529a67210748f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApmRoute } from '@elastic/apm-rum-react'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { Route, Router, Switch } from 'react-router-dom'; +import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; +import { + KibanaContextProvider, + RedirectAppLinks, + useUiSetting$, +} from '../../../../../../src/plugins/kibana_react/public'; +import { ScrollToTopOnPathChange } from '../../components/app/Main/ScrollToTopOnPathChange'; +import { + ApmPluginContext, + ApmPluginContextValue, +} from '../../context/apm_plugin/apm_plugin_context'; +import { LicenseProvider } from '../../context/license/license_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { ApmPluginStartDeps } from '../../plugin'; +import { HeaderMenuPortal } from '../../../../observability/public'; +import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { AnomalyDetectionJobsContextProvider } from '../../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; +import { apmRouteConfig } from './apm_route_config'; + +const MainContainer = euiStyled.div` + height: 100%; +`; + +export function ApmAppRoot({ + apmPluginContextValue, + pluginsStart, +}: { + apmPluginContextValue: ApmPluginContextValue; + pluginsStart: ApmPluginStartDeps; +}) { + const { appMountParameters, core } = apmPluginContextValue; + const { history } = appMountParameters; + const i18nCore = core.i18n; + + return ( + + + + + + + + + + + + + + + {apmRouteConfig.map((route, i) => ( + + ))} + + + + + + + + + + + + ); +} + +function MountApmHeaderActionMenu() { + useBreadcrumbs(apmRouteConfig); + const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; + + return ( + + + + ); +} + +function ApmThemeProvider({ children }: { children: React.ReactNode }) { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + ({ + ...outerTheme, + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + })} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/redirect_to.tsx b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx new file mode 100644 index 00000000000000..68ff2fce77f137 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +/** + * Given a path, redirect to that location, preserving the search and maintaining + * backward-compatibilty with legacy (pre-7.9) hash-based URLs. + */ +export function redirectTo(to: string) { + return ({ location }: RouteComponentProps<{}>) => { + let resolvedUrl: URL | undefined; + + // Redirect root URLs with a hash to support backward compatibility with URLs + // from before we switched to the non-hash platform history. + if (location.pathname === '' && location.hash.length > 0) { + // We just want the search and pathname so the host doesn't matter + resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); + to = resolvedUrl.pathname; + } + + return ( + + ); + }; +} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx rename to x-pack/plugins/apm/public/components/routing/route_config.test.tsx index 62202d9489d51c..b1d5c1a83b43bb 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { routes } from './'; +import { apmRouteConfig } from './apm_route_config'; describe('routes', () => { describe('/', () => { - const route = routes.find((r) => r.path === '/'); + const route = apmRouteConfig.find((r) => r.path === '/'); describe('with no hash path', () => { it('redirects to /services', () => { diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx similarity index 86% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx rename to x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx index e5d238e6aa89c3..8e0a08603bc76d 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { useFetcher } from '../../../../../hooks/use_fetcher'; -import { toQuery } from '../../../../shared/Links/url_helpers'; -import { Settings } from '../../../Settings'; -import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { toQuery } from '../../shared/Links/url_helpers'; +import { Settings } from '../../app/Settings'; +import { AgentConfigurationCreateEdit } from '../../app/Settings/AgentConfigurations/AgentConfigurationCreateEdit'; type EditAgentConfigurationRouteHandler = RouteComponentProps<{}>; diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx new file mode 100644 index 00000000000000..0473e88c23d12f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../../../plugin'; +import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; + +/* + * This template contains: + * - The Shared Observability Nav (https://github.com/elastic/kibana/blob/f7698bd8aa8787d683c728300ba4ca52b202369c/x-pack/plugins/observability/public/components/shared/page_template/README.md) + * - The APM Header Action Menu + * - Page title + * + * Optionally: + * - EnvironmentFilter + */ +export function ApmMainTemplate({ + pageTitle, + children, +}: { + pageTitle: React.ReactNode; + children: React.ReactNode; +}) { + const { services } = useKibana(); + const ObservabilityPageTemplate = + services.observability.navigation.PageTemplate; + + return ( + ], + }} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx new file mode 100644 index 00000000000000..526d9eb3551d0a --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiTabs, + EuiTab, + EuiBetaBadge, +} from '@elastic/eui'; +import { ApmMainTemplate } from './apm_main_template'; +import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; +import { enableServiceOverview } from '../../../../common/ui_settings_keys'; +import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; +import { ServiceIcons } from '../../shared/service_icons'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; +import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; +import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; +import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; +import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; +import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { Correlations } from '../../app/correlations'; +import { SearchBar } from '../../shared/search_bar'; + +interface Tab { + key: TabKey; + href: string; + text: React.ReactNode; + hidden?: boolean; +} + +type TabKey = + | 'errors' + | 'metrics' + | 'nodes' + | 'overview' + | 'service-map' + | 'profiling' + | 'transactions'; + +export function ApmServiceTemplate({ + children, + serviceName, + selectedTab, + searchBarOptions, +}: { + children: React.ReactNode; + serviceName: string; + selectedTab: TabKey; + searchBarOptions?: { + hidden?: boolean; + showTransactionTypeSelector?: boolean; + showTimeComparison?: boolean; + }; +}) { + return ( + + + + +

{serviceName}

+
+
+ + + +
+ + } + > + + + + + + + + + + + + + {children} + +
+ ); +} + +function TabNavigation({ + serviceName, + selectedTab, +}: { + serviceName: string; + selectedTab: TabKey; +}) { + const { agentName, transactionType } = useApmServiceContext(); + const { core, config } = useApmPluginContext(); + const { urlParams } = useUrlParams(); + + const tabs: Tab[] = [ + { + key: 'overview', + href: useServiceOverviewHref({ serviceName, transactionType }), + text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { + defaultMessage: 'Overview', + }), + hidden: !core.uiSettings.get(enableServiceOverview), + }, + { + key: 'transactions', + href: useTransactionsOverviewHref({ + serviceName, + latencyAggregationType: urlParams.latencyAggregationType, + transactionType, + }), + text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { + defaultMessage: 'Transactions', + }), + }, + { + key: 'errors', + href: useErrorOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { + defaultMessage: 'Errors', + }), + }, + { + key: 'nodes', + href: useServiceNodeOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { + defaultMessage: 'JVMs', + }), + hidden: !isJavaAgentName(agentName), + }, + { + key: 'metrics', + href: useMetricOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { + defaultMessage: 'Metrics', + }), + hidden: + !agentName || isRumAgentName(agentName) || isJavaAgentName(agentName), + }, + { + key: 'service-map', + href: useServiceMapHref(serviceName), + text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { + defaultMessage: 'Service Map', + }), + }, + { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + hidden: !config.profilingEnabled, + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + }, + ]; + + return ( + + {tabs + .filter((t) => !t.hidden) + .map(({ href, key, text }) => ( + + {text} + + ))} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx deleted file mode 100644 index 4bc9764b704b00..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTitle } from '@elastic/eui'; -import React, { ComponentType } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CoreStart } from '../../../../../../../src/core/public'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; -import { createCallApmApi } from '../../../services/rest/createCallApmApi'; -import { ApmHeader } from './'; - -export default { - title: 'shared/ApmHeader', - component: ApmHeader, - decorators: [ - (Story: ComponentType) => { - createCallApmApi(({} as unknown) as CoreStart); - - return ( - - - - - - - - - - ); - }, - ], -}; - -export function Example() { - return ( - - -

- GET - /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all -

-
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx deleted file mode 100644 index f94bba84526a78..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { EnvironmentFilter } from '../EnvironmentFilter'; - -const HeaderFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; -`; - -export function ApmHeader({ children }: { children: ReactNode }) { - const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; - - return ( - - - - - {children} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index 59c99463144cbe..c1bef7ac407ffa 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -13,6 +13,7 @@ import { useHistory, useLocation, useParams } from 'react-router-dom'; import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, + omitEsFieldValue, } from '../../../../common/environment_filter_values'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -51,9 +52,9 @@ function getOptions(environments: string[]) { })); return [ - ENVIRONMENT_ALL, + omitEsFieldValue(ENVIRONMENT_ALL), ...(environments.includes(ENVIRONMENT_NOT_DEFINED.value) - ? [ENVIRONMENT_NOT_DEFINED] + ? [omitEsFieldValue(ENVIRONMENT_NOT_DEFINED)] : []), ...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []), ...environmentOptions, @@ -78,12 +79,14 @@ export function EnvironmentFilter() { // the contents. const minWidth = 200; + const options = getOptions(environments); + return ( { updateEnvironmentUrl(history, location, event.target.value); diff --git a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx similarity index 96% rename from x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 2ff3756855d142..95acc55196c543 100644 --- a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -13,9 +13,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { IBasePath } from '../../../../../../src/core/public'; -import { AlertType } from '../../../common/alert_types'; -import { AlertingFlyout } from '../../components/alerting/alerting_flyout'; +import { IBasePath } from '../../../../../../../src/core/public'; +import { AlertType } from '../../../../common/alert_types'; +import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx index 044abbb2ec792b..6a6ba3f9529ffa 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { MissingJobsAlert } from './anomaly_detection_setup_link'; -import * as hooks from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; +import * as hooks from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; async function renderTooltipAnchor({ jobs, diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx similarity index 83% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx index 296e55fdff82ba..ade49bc7e3aa4f 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { ENVIRONMENT_ALL, getEnvironmentLabel, -} from '../../../common/environment_filter_values'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useAnomalyDetectionJobsContext } from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -import { useLicenseContext } from '../../context/license/use_license_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; -import { APIReturnType } from '../../services/rest/createCallApmApi'; -import { units } from '../../style/variables'; +} from '../../../../common/environment_filter_values'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useLicenseContext } from '../../../context/license/use_license_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { units } from '../../../style/variables'; export type AnomalyDetectionApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/jobs'>; diff --git a/x-pack/plugins/apm/public/application/action_menu/index.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx similarity index 88% rename from x-pack/plugins/apm/public/application/action_menu/index.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx index 2d9b619a3176d3..134941990a0f4c 100644 --- a/x-pack/plugins/apm/public/application/action_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx @@ -9,13 +9,13 @@ import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { getAlertingCapabilities } from '../../components/alerting/get_alerting_capabilities'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { getAlertingCapabilities } from '../../alerting/get_alerting_capabilities'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; -export function ActionMenu() { +export function ApmHeaderActionMenu() { const { core, plugins } = useApmPluginContext(); const { serviceName } = useParams<{ serviceName?: string }>(); const { search } = window.location; diff --git a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx deleted file mode 100644 index f60da7c3087115..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiTabs } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; - -// Since our `EuiTab` components have `APMLink`s inside of them and not just -// `href`s, we need to override the color of the links inside or they will all -// be the primary color. -const StyledTabs = euiStyled(EuiTabs)` - padding: ${({ theme }) => `${theme.eui.gutterTypes.gutterMedium}`}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - border-top: ${({ theme }) => theme.eui.euiBorderThin}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; -`; - -export function MainTabs({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx new file mode 100644 index 00000000000000..105bdb008042e9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getByTestId, fireEvent, getByText } from '@testing-library/react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { MockApmPluginContextWrapper } from '../../context/apm_plugin/mock_apm_plugin_context'; +import { ApmServiceContextProvider } from '../../context/apm_service/apm_service_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { IUrlParams } from '../../context/url_params_context/types'; +import * as useFetcherHook from '../../hooks/use_fetcher'; +import * as useServiceTransactionTypesHook from '../../context/apm_service/use_service_transaction_types_fetcher'; +import { renderWithTheme } from '../../utils/testHelpers'; +import { fromQuery } from './Links/url_helpers'; +import { CoreStart } from 'kibana/public'; +import { SearchBar } from './search_bar'; + +function setup({ + urlParams, + serviceTransactionTypes, + history, +}: { + urlParams: IUrlParams; + serviceTransactionTypes: string[]; + history: MemoryHistory; +}) { + history.replace({ + pathname: '/services/foo/transactions', + search: fromQuery(urlParams), + }); + + const KibanaReactContext = createKibanaReactContext({ + usageCollection: { reportUiCounter: () => {} }, + } as Partial); + + // mock transaction types + jest + .spyOn(useServiceTransactionTypesHook, 'useServiceTransactionTypesFetcher') + .mockReturnValue(serviceTransactionTypes); + + jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); + + return renderWithTheme( + + + + + + + + + + + + ); +} + +describe('when transactionType is selected and multiple transaction types are given', () => { + let history: MemoryHistory; + beforeEach(() => { + history = createMemoryHistory(); + jest.spyOn(history, 'push'); + jest.spyOn(history, 'replace'); + }); + + it('renders a radio group with transaction types', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + + // both options should be listed + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // second option should be selected + expect(dropdown).toHaveValue('secondType'); + }); + + it('should update the URL when a transaction type is selected', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + expect(history.location.search).toEqual( + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' + ); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // change dropdown value + fireEvent.change(dropdown, { target: { value: 'firstType' } }); + + // assert that value was changed + expect(dropdown).toHaveValue('firstType'); + expect(history.push).toHaveBeenCalled(); + expect(history.location.search).toEqual( + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' + ); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index f0fc18cf266b9e..17497e1fb4b30a 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -15,7 +15,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { enableInspectEsQueries } from '../../../../observability/public'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; import { useKibanaUrl } from '../../hooks/useKibanaUrl'; @@ -26,12 +25,9 @@ import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; import { TransactionTypeSelect } from './transaction_type_select'; -const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` - margin: ${({ theme }) => - `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; -`; - interface Props { + hidden?: boolean; + showKueryBar?: boolean; showTimeComparison?: boolean; showTransactionTypeSelector?: boolean; } @@ -49,7 +45,7 @@ function DebugQueryCallout() { } return ( - + - + ); } export function SearchBar({ + hidden = false, + showKueryBar = true, showTimeComparison = false, showTransactionTypeSelector = false, }: Props) { const { isSmall, isMedium, isLarge, isXl, isXXL } = useBreakPoints(); + + if (hidden) { + return null; + } + return ( <> - )} - - - + + {showKueryBar && ( + + + + )} @@ -128,7 +134,7 @@ export function SearchBar({ - + ); diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx index 0066480230c6bf..9f6378ccb44971 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx @@ -14,12 +14,12 @@ import { RULE_ID, RULE_NAME, } from '@kbn/rule-data-utils/target/technical_field_names'; -import { parseTechnicalFields } from '../../../../../../rule_registry/common'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { asPercent, asDuration } from '../../../../../common/utils/formatters'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { parseTechnicalFields } from '../../../../../rule_registry/common'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { asPercent, asDuration } from '../../../../common/utils/formatters'; +import { TimestampTooltip } from '../TimestampTooltip'; interface AlertDetailProps { alerts: APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts']; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx index 2e19bc684d6815..2e8fcfa1df6725 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx index efc9a46526cf87..b590a67409d9ee 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx @@ -9,8 +9,8 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { asInteger } from '../../../../../common/utils/formatters'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { asInteger } from '../../../../common/utils/formatters'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx index 79f93ea76ee51f..05305558564f13 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx @@ -13,8 +13,8 @@ import { EuiPopoverTitle, } from '@elastic/eui'; import React from 'react'; -import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; -import { px } from '../../../../style/variables'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { px } from '../../../style/variables'; interface IconPopoverProps { title: string; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index 6027e8b1d07c59..d66625f613cdcc 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -11,14 +11,14 @@ import { merge } from 'lodash'; // import { renderWithTheme } from '../../../../utils/testHelpers'; import React, { ReactNode } from 'react'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; -import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; -import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, -} from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import * as fetcherHook from '../../../../hooks/use_fetcher'; -import { ServiceIcons } from './'; +} from '../../../context/apm_plugin/mock_apm_plugin_context'; +import * as fetcherHook from '../../../hooks/use_fetcher'; +import { ServiceIcons } from '.'; import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; const KibanaReactContext = createKibanaReactContext({ diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index f7bed4e09a696d..d64605da2bc3f9 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -8,12 +8,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactChild, useState } from 'react'; -import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useTheme } from '../../../../hooks/use_theme'; -import { ContainerType } from '../../../../../common/service_metadata'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useTheme } from '../../../hooks/use_theme'; +import { ContainerType } from '../../../../common/service_metadata'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { getAgentIcon } from '../AgentIcon/get_agent_icon'; import { CloudDetails } from './cloud_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx index ed503a5cb34a03..1828465fff450a 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx @@ -9,7 +9,7 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx index 13a2fa3b227da9..64990651b52bbd 100644 --- a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx @@ -9,7 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import produce from 'immer'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { routes } from '../components/app/Main/route_config'; +import { apmRouteConfig } from '../components/routing/apm_route_config'; import { ApmPluginContextValue } from '../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, @@ -36,7 +36,9 @@ function createWrapper(path: string) { } function mountBreadcrumb(path: string) { - renderHook(() => useBreadcrumbs(routes), { wrapper: createWrapper(path) }); + renderHook(() => useBreadcrumbs(apmRouteConfig), { + wrapper: createWrapper(path), + }); } const changeTitle = jest.fn(); diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 10af1837dab42f..845b18b707f930 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { of } from 'rxjs'; import type { ConfigSchema } from '.'; import { AppMountParameters, @@ -34,6 +35,7 @@ import type { FetchDataParams, HasDataParams, ObservabilityPublicSetup, + ObservabilityPublicStart, } from '../../observability/public'; import type { TriggersAndActionsUIPublicPluginSetup, @@ -48,24 +50,25 @@ export type ApmPluginStart = void; export interface ApmPluginSetupDeps { alerting?: AlertingPluginPublicSetup; - ml?: MlPluginSetup; data: DataPublicPluginSetup; features: FeaturesPluginSetup; home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + ml?: MlPluginSetup; observability: ObservabilityPublicSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; } export interface ApmPluginStartDeps { alerting?: AlertingPluginPublicStart; - ml?: MlPluginStart; data: DataPublicPluginStart; + embeddable: EmbeddableStart; home: void; licensing: void; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - embeddable: EmbeddableStart; maps?: MapsStartApi; + ml?: MlPluginStart; + observability: ObservabilityPublicStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class ApmPlugin implements Plugin { @@ -83,6 +86,21 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); } + // register observability nav + plugins.observability.navigation.registerSections( + of([ + { + label: 'APM', + sortKey: 200, + entries: [ + { label: 'Services', app: 'apm', path: '/services' }, + { label: 'Traces', app: 'apm', path: '/traces' }, + { label: 'Service Map', app: 'apm', path: '/service-map' }, + ], + }, + ]) + ); + const getApmDataHelper = async () => { const { fetchObservabilityOverviewPageData, diff --git a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts deleted file mode 100644 index 7b4a07dc7bbc53..00000000000000 --- a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { flatten } from 'lodash'; -import { TimeSeries } from '../../typings/timeseries'; - -export function getRangeFromTimeSeries(timeseries: TimeSeries[]) { - const dataPoints = flatten(timeseries.map((series) => series.data)); - - if (dataPoints.length) { - return { - start: dataPoints[0].x, - end: dataPoints[dataPoints.length - 1].x, - }; - } - - return null; -} diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index ef2675f4f6c65c..9cfb6210e25415 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -124,7 +124,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --emitDeclarationOnly false --project x-pack/plugins/apm/tsconfig.json --skipLibCheck +node scripts/type_check.js --project x-pack/plugins/apm/tsconfig.json ``` ### Prettier @@ -136,7 +136,7 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ### ESLint ``` -yarn eslint ./x-pack/plugins/apm --fix +node scripts/eslint.js x-pack/legacy/plugins/apm ``` ## Setup default APM users diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 7117973baa1393..95f91165aaf94e 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -16,7 +16,7 @@ export interface FleetConfigType { agents: { enabled: boolean; elasticsearch: { - host?: string; + hosts?: string[]; ca_sha256?: string; }; fleet_server?: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts index 7f0b71de779dc6..a9ad6b1bd87941 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -15,7 +15,7 @@ export const createConfigurationMock = (): FleetConfigType => { agents: { enabled: true, elasticsearch: { - host: '', + hosts: [''], ca_sha256: '', }, }, diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index e83617413b7442..0a886ffedbd6c0 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -41,6 +41,23 @@ export const config: PluginConfigDescriptor = { unused('agents.pollingRequestTimeout'), unused('agents.tlsCheckDisabled'), unused('agents.fleetServerEnabled'), + (fullConfig, fromPath, addDeprecation) => { + const oldValue = fullConfig?.xpack?.fleet?.agents?.elasticsearch?.host; + if (oldValue) { + delete fullConfig.xpack.fleet.agents.elasticsearch.host; + fullConfig.xpack.fleet.agents.elasticsearch.hosts = [oldValue]; + addDeprecation({ + message: `Config key [xpack.fleet.agents.elasticsearch.host] is deprecated and replaced by [xpack.fleet.agents.elasticsearch.hosts]`, + correctiveActions: { + manualSteps: [ + `Use [xpack.fleet.agents.elasticsearch.hosts] with an array of host instead.`, + ], + }, + }); + } + + return fullConfig; + }, ], schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -49,7 +66,7 @@ export const config: PluginConfigDescriptor = { agents: schema.object({ enabled: schema.boolean({ defaultValue: true }), elasticsearch: schema.object({ - host: schema.maybe(schema.string()), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), }), fleet_server: schema.maybe( diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts new file mode 100644 index 00000000000000..4c0484c058abf0 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +export const FINAL_PIPELINE = `--- +description: > + Final pipeline for processing all incoming Fleet Agent documents. +processors: + - set: + description: Add time when event was ingested. + field: event.ingested + value: '{{{_ingest.timestamp}}}' + - remove: + description: Remove any pre-existing untrusted values. + field: + - event.agent_id_status + - _security + ignore_missing: true + - set_security_user: + field: _security + properties: + - authentication_type + - username + - realm + - api_key + - script: + description: > + Add event.agent_id_status based on the API key metadata and the + agent.id contained in the event. + tag: agent-id-status + source: |- + boolean is_user_trusted(def ctx, def users) { + if (ctx?._security?.username == null) { + return false; + } + + def user = null; + for (def item : users) { + if (item?.username == ctx._security.username) { + user = item; + break; + } + } + + if (user == null || user?.realm == null || ctx?._security?.realm?.name == null) { + return false; + } + + if (ctx._security.realm.name != user.realm) { + return false; + } + + return true; + } + + String verified(def ctx, def params) { + // Agents only use API keys. + if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { + return "no_api_key"; + } + + // Verify the API key owner before trusting any metadata it contains. + if (!is_user_trusted(ctx, params.trusted_users)) { + return "untrusted_user"; + } + + // API keys created by Fleet include metadata about the agent they were issued to. + if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { + return "missing_metadata"; + } + + // The API key can only be used represent the agent.id it was issued to. + if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { + // Potential masquerade attempt. + return "agent_id_mismatch"; + } + + return "verified"; + } + + if (ctx?.event == null) { + ctx.event = [:]; + } + + ctx.event.agent_id_status = verified(ctx, params); + params: + # List of users responsible for creating Fleet output API keys. + trusted_users: + - username: elastic + realm: reserved + - remove: + field: _security + ignore_missing: true +on_failure: + - remove: + field: _security + ignore_missing: true + ignore_failure: true + - append: + field: error.message + value: + - 'failed in Fleet agent final_pipeline: {{ _ingest.on_failure_message }}'`; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index ac5aca7ab1c143..1d212f188120f4 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -16,6 +16,7 @@ import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; import { deletePipelineRefs } from './remove'; +import { FINAL_PIPELINE, FINAL_PIPELINE_ID } from './final_pipeline'; interface RewriteSubstitution { source: string; @@ -185,6 +186,28 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } +export async function ensureFleetFinalPipelineIsInstalled(esClient: ElasticsearchClient) { + const esClientRequestOptions: TransportRequestOptions = { + ignore: [404], + }; + const res = await esClient.ingest.getPipeline({ id: FINAL_PIPELINE_ID }, esClientRequestOptions); + + if (res.statusCode === 404) { + await esClient.ingest.putPipeline( + // @ts-ignore pipeline is define in yaml + { id: FINAL_PIPELINE_ID, body: FINAL_PIPELINE }, + { + headers: { + // pipeline is YAML + 'Content-Type': 'application/yaml', + // but we want JSON responses (to extract error messages, status code, or other metadata) + Accept: 'application/json', + }, + } + ); + } +} + const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 65eec939d58502..acf8ae742bf8f1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -25,7 +25,8 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` "default_field": [ "long.nested.foo" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -139,7 +140,8 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "coredns.response.code", "coredns.response.flags" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -281,7 +283,8 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "system.users.scope", "system.users.remote_host" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 64261226a79441..5dd2755390ecb1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -16,6 +16,7 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; +import { FINAL_PIPELINE_ID } from '../ingest_pipeline/final_pipeline'; interface Properties { [key: string]: any; @@ -86,6 +87,11 @@ export function getTemplate({ if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } + if (template.template.settings.index.final_pipeline) { + throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); + } + template.template.settings.index.final_pipeline = FINAL_PIPELINE_ID; + return template; } diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts new file mode 100644 index 00000000000000..26e3955607adaf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { outputService } from './output'; + +import { appContextService } from './app_context'; + +jest.mock('./app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +const CLOUD_ID = + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw=='; + +const CONFIG_WITH_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: { + hosts: ['http://host1.com'], + }, + }, +}; + +const CONFIG_WITHOUT_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: {}, + }, +}; + +describe('Output Service', () => { + describe('getDefaultESHosts', () => { + afterEach(() => { + mockedAppContextService.getConfig.mockReset(); + mockedAppContextService.getConfig.mockReset(); + }); + it('Should use cloud ID as the source of truth for ES hosts', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: true, + cloudId: CLOUD_ID, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual([ + 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + ]); + }); + + it('Should use the value from the config if not in cloud', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://host1.com']); + }); + + it('Should use the default value if there is no config', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITHOUT_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://localhost:9200']); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index b3857ba5c0ef37..0c7b086f78fdf8 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -16,6 +16,8 @@ import { normalizeHostsForAgents } from './hosts_utils'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; +const DEFAULT_ES_HOSTS = ['http://localhost:9200']; + class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -27,17 +29,11 @@ class OutputService { public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; - const flagsUrl = appContextService.getConfig()!.agents.elasticsearch.host; - const defaultUrl = 'http://localhost:9200'; - const defaultOutputUrl = cloudUrl || flagsUrl || defaultUrl; if (!outputs.saved_objects.length) { const newDefaultOutput = { ...DEFAULT_OUTPUT, - hosts: [defaultOutputUrl], + hosts: this.getDefaultESHosts(), ca_sha256: appContextService.getConfig()!.agents.elasticsearch.ca_sha256, } as NewOutput; @@ -50,6 +46,20 @@ class OutputService { }; } + public getDefaultESHosts(): string[] { + const cloud = appContextService.getCloud(); + const cloudId = cloud?.isCloudEnabled && cloud.cloudId; + const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; + const cloudHosts = cloudUrl ? [cloudUrl] : undefined; + const flagHosts = + appContextService.getConfig()!.agents?.elasticsearch?.hosts && + appContextService.getConfig()!.agents.elasticsearch.hosts?.length + ? appContextService.getConfig()!.agents.elasticsearch.hosts + : undefined; + + return cloudHosts || flagHosts || DEFAULT_ES_HOSTS; + } + public async getDefaultOutputId(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 28deec8a890283..7f4219799e511e 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -20,6 +20,7 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; +import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; export interface SetupStatus { isInitialized: boolean; @@ -42,6 +43,8 @@ async function createSetupSideEffects( settingsService.settingsSetup(soClient), ]); + await ensureFleetFinalPipelineIsInstalled(esClient); + await awaitIfFleetServerSetupPending(); const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 9dd0d6cc72de17..c00f09b2d2b06c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -154,6 +154,10 @@ type TestSubject = | 'separatorValueField.input' | 'quoteValueField.input' | 'emptyValueField.input' + | 'valueFieldInput' + | 'mediaTypeSelectorField' + | 'ignoreEmptyField.input' + | 'overrideField.input' | 'fieldsValueField.input' | 'saltValueField.input' | 'methodsValueField' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx new file mode 100644 index 00000000000000..d7351c9dbf65f6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the set processor when saved +const defaultSetParameters = { + value: '', + if: undefined, + tag: undefined, + override: undefined, + media_type: undefined, + description: undefined, + ignore_missing: undefined, + ignore_failure: undefined, + ignore_empty_value: undefined, +}; + +const SET_TYPE = 'set'; + +describe('Processor: Set', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(SET_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with default parameter value', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + }); + }); + + test('should allow to set mediaType when value is a template snippet', async () => { + const { + actions: { saveNewProcessor }, + form, + exists, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Shouldnt be able to set mediaType if value is not a template string + form.setInputValue('valueFieldInput', 'hello'); + expect(exists('mediaTypeSelectorField')).toBe(false); + + // Set value to a template snippet and media_type to a non-default value + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.setSelectValue('mediaTypeSelectorField', 'text/plain'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + media_type: 'text/plain', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.toggleEuiSwitch('overrideField.input'); + form.toggleEuiSwitch('ignoreEmptyField.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + ignore_empty_value: true, + override: false, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx index 89ca373b9e6539..fda34f8700b33c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx @@ -10,7 +10,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode } from '@elastic/eui'; -import { FIELD_TYPES, ToggleField, UseField, Field } from '../../../../../../shared_imports'; +import { + FIELD_TYPES, + useFormData, + SelectField, + ToggleField, + UseField, + Field, +} from '../../../../../../shared_imports'; +import { hasTemplateSnippet } from '../../../utils'; import { FieldsConfig, to, from } from './shared'; @@ -35,6 +43,20 @@ const fieldsConfig: FieldsConfig = { /> ), }, + mediaType: { + type: FIELD_TYPES.SELECT, + defaultValue: 'application/json', + serializer: from.undefinedIfValue('application/json'), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.mediaTypeFieldLabel', { + defaultMessage: 'Media Type', + }), + helpText: ( + + ), + }, /* Optional fields config */ override: { type: FIELD_TYPES.TOGGLE, @@ -83,6 +105,8 @@ const fieldsConfig: FieldsConfig = { * Disambiguate name from the Set data structure */ export const SetProcessor: FunctionComponent = () => { + const [{ fields }] = useFormData({ watch: 'fields.value' }); + return ( <> { path="fields.value" /> - + {hasTemplateSnippet(fields?.value) && ( + + )} + + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts index 6f21285398e1fa..6e367a83bf8d44 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getValue, setValue } from './utils'; +import { getValue, setValue, hasTemplateSnippet } from './utils'; describe('get and set values', () => { const testObject = Object.freeze([{ onFailure: [{ onFailure: 1 }] }]); @@ -35,3 +35,19 @@ describe('get and set values', () => { }); }); }); + +describe('template snippets', () => { + it('knows when a string contains an invalid template snippet', () => { + expect(hasTemplateSnippet('')).toBe(false); + expect(hasTemplateSnippet('{}')).toBe(false); + expect(hasTemplateSnippet('{{{}}}')).toBe(false); + expect(hasTemplateSnippet('{{hello}}')).toBe(false); + }); + + it('knows when a string contains a valid template snippet', () => { + expect(hasTemplateSnippet('{{{hello}}}')).toBe(true); + expect(hasTemplateSnippet('hello{{{world}}}')).toBe(true); + expect(hasTemplateSnippet('{{{hello}}}world')).toBe(true); + expect(hasTemplateSnippet('{{{hello.world}}}')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts index e07b9eba90622c..1259dbd5a9b91c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts @@ -102,3 +102,20 @@ export const checkIfSamePath = (pathA: ProcessorSelector, pathB: ProcessorSelect if (pathA.length !== pathB.length) return false; return pathA.join('.') === pathB.join('.'); }; + +/* + * Given a string it checks if it contains a valid mustache template snippet. + * + * Note: This allows strings with spaces such as: {{{hello world}}}. I figured we + * should use .+ instead of \S (disallow all whitespaces) because the backend seems + * to allow spaces inside the template snippet anyway. + * + * See: https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html#template-snippets + */ +export const hasTemplateSnippet = (str: string = '') => { + // Matches when: + // * contains a {{{ + // * Followed by all strings of length >= 1 + // * And followed by }}} + return /{{{.+}}}/.test(str); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx index 65bc23b4eb1cad..68705ebf2d1578 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -114,7 +114,7 @@ export function Filtering({ } > { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx index 6d1cc3254ca7e8..1c2e64735ca16b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx @@ -13,6 +13,7 @@ import { createMockedIndexPattern } from '../../../mocks'; import { FilterPopover } from './filter_popover'; import { LabelInput } from '../shared_components'; import { QueryInput } from '../../../query_input'; +import { QueryStringInput } from '../../../../../../../../src/plugins/data/public'; jest.mock('.', () => ({ isQueryValid: () => true, @@ -32,13 +33,25 @@ const defaultProps = { ), initiallyOpen: true, }; +jest.mock('../../../../../../../../src/plugins/data/public', () => ({ + QueryStringInput: () => { + return 'QueryStringInput'; + }, +})); describe('filter popover', () => { - jest.mock('../../../../../../../../src/plugins/data/public', () => ({ - QueryStringInput: () => { - return 'QueryStringInput'; - }, - })); + it('passes correct props to QueryStringInput', () => { + const instance = mount(); + instance.update(); + expect(instance.find(QueryStringInput).props()).toEqual( + expect.objectContaining({ + dataTestSubj: 'indexPattern-filters-queryStringInput', + indexPatterns: ['my-fake-index-pattern'], + isInvalid: false, + query: { language: 'kuery', query: 'bytes >= 1' }, + }) + ); + }); it('should be open if is open by creation', () => { const instance = mount(); instance.update(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index f5428bf24348f6..bfb0cffece57c4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -75,7 +75,7 @@ export const FilterPopover = ({ { if (inputRef.current) inputRef.current.focus(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx index 6c2b62f96eaec3..a67199a9d34325 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -7,21 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { IndexPattern } from './types'; import { QueryStringInput, Query } from '../../../../../src/plugins/data/public'; import { useDebouncedValue } from '../shared_components'; export const QueryInput = ({ value, onChange, - indexPattern, + indexPatternTitle, isInvalid, onSubmit, disableAutoFocus, }: { value: Query; onChange: (input: Query) => void; - indexPattern: IndexPattern; + indexPatternTitle: string; isInvalid: boolean; onSubmit: () => void; disableAutoFocus?: boolean; @@ -35,7 +34,7 @@ export const QueryInput = ({ disableAutoFocus={disableAutoFocus} isInvalid={isInvalid} bubbleSubmitEvent={false} - indexPatterns={[indexPattern]} + indexPatterns={[indexPatternTitle]} query={inputValue} onChange={handleInputChange} onSubmit={() => { diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index f413b122d913cc..6e04d1a4ff9583 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; @@ -265,10 +265,15 @@ export const getPieVisualization = ({ } } return metricColumnsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 8fbc8e8b2ef7ab..c1041e1fefcfd1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -929,12 +929,17 @@ describe('xy_visualization', () => { ); expect(warningMessages).toHaveLength(1); expect(warningMessages && warningMessages[0]).toMatchInlineSnapshot(` - - - Label B - - contains array values. Your visualization may not render as expected. - + + Label B + , + } + } + /> `); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index fa9d46be11d686..ad2c9fd7139853 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -439,10 +439,15 @@ export const getXyVisualization = ({ } } return accessorsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, }); diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts new file mode 100644 index 00000000000000..9ce405804bde16 --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { lensEmbeddableFactory } from './lens_embeddable_factory'; +import { migrations } from '../migrations/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(migrations).filter((version) => { + return semverGte(version, '7.13.1'); + }); + const embeddableMigrationVersions = lensEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); diff --git a/x-pack/plugins/maps/server/embeddable_migrations.test.ts b/x-pack/plugins/maps/server/embeddable_migrations.test.ts new file mode 100644 index 00000000000000..306f716d5171d3 --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { embeddableMigrations } from './embeddable_migrations'; +// @ts-ignore +import { savedObjectMigrations } from './saved_objects/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.12)', () => { + const savedObjectMigrationVersions = Object.keys(savedObjectMigrations).filter((version) => { + return semverGte(version, '7.13.0'); + }); + const embeddableMigrationVersions = Object.keys(embeddableMigrations); + expect(savedObjectMigrationVersions.sort()).toEqual(embeddableMigrationVersions.sort()); + }); +}); diff --git a/x-pack/plugins/maps/server/embeddable_migrations.ts b/x-pack/plugins/maps/server/embeddable_migrations.ts new file mode 100644 index 00000000000000..4bf39dc1f999c5 --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableState } from '../../../../src/plugins/kibana_utils/common'; +import { MapSavedObjectAttributes } from '../common/map_saved_object_type'; +import { moveAttribution } from '../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the embeddable migration registry. + */ +export const embeddableMigrations = { + '7.14.0': (state: SerializableState) => { + return { + ...state, + attributes: moveAttribution(state as { attributes: MapSavedObjectAttributes }), + } as SerializableState; + }, +}; diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index f0c8a051f8f796..c7532979320378 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -37,6 +37,8 @@ import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { MapsEmsPluginSetup } from '../../../../src/plugins/maps_ems/server'; import { EMSSettings } from '../common/ems_settings'; import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; +import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; +import { embeddableMigrations } from './embeddable_migrations'; interface SetupDeps { features: FeaturesPluginSetupContract; @@ -44,6 +46,7 @@ interface SetupDeps { home: HomeServerPluginSetup; licensing: LicensingPluginSetup; mapsEms: MapsEmsPluginSetup; + embeddable: EmbeddableSetup; } export interface StartDeps { @@ -214,6 +217,11 @@ export class MapsPlugin implements Plugin { core.savedObjects.registerType(mapSavedObjects); registerMapsUsageCollector(usageCollection, currentConfig); + plugins.embeddable.registerEmbeddableFactory({ + id: MAP_SAVED_OBJECT_TYPE, + migrations: embeddableMigrations, + }); + return { config: config$, }; diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index f4091db66d3da7..78f70e27b2b7bf 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from 'src/core/server'; import { APP_ICON, getExistingMapPath } from '../../common/constants'; // @ts-ignore -import { migrations } from './migrations'; +import { savedObjectMigrations } from './saved_object_migrations'; export const mapSavedObjects: SavedObjectsType = { name: 'map', @@ -39,5 +39,5 @@ export const mapSavedObjects: SavedObjectsType = { }; }, }, - migrations: migrations.map, + migrations: savedObjectMigrations, }; diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js deleted file mode 100644 index d10e22722970a8..00000000000000 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { extractReferences } from '../../common/migrations/references'; -import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; -import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; -import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; -import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; -import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; -import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; -import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; -import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; -import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; -import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; -import { moveAttribution } from '../../common/migrations/move_attribution'; - -export const migrations = { - map: { - '7.2.0': (doc) => { - const { attributes, references } = extractReferences(doc); - - return { - ...doc, - attributes, - references, - }; - }, - '7.4.0': (doc) => { - const attributes = emsRasterTileToEmsVectorTile(doc); - - return { - ...doc, - attributes, - }; - }, - '7.5.0': (doc) => { - const attributes = topHitsTimeToSort(doc); - - return { - ...doc, - attributes, - }; - }, - '7.6.0': (doc) => { - const attributesPhase1 = moveApplyGlobalQueryToSources(doc); - const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.7.0': (doc) => { - const attributesPhase1 = migrateSymbolStyleDescriptor(doc); - const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.8.0': (doc) => { - const attributes = migrateJoinAggKey(doc); - - return { - ...doc, - attributes, - }; - }, - '7.9.0': (doc) => { - const attributes = removeBoundsFromSavedObject(doc); - - return { - ...doc, - attributes, - }; - }, - '7.10.0': (doc) => { - const attributes = setDefaultAutoFitToBounds(doc); - - return { - ...doc, - attributes, - }; - }, - '7.12.0': (doc) => { - const attributes = addTypeToTermJoin(doc); - - return { - ...doc, - attributes, - }; - }, - '7.14.0': (doc) => { - const attributes = moveAttribution(doc); - - return { - ...doc, - attributes, - }; - }, - }, -}; diff --git a/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js new file mode 100644 index 00000000000000..8866ebb6b3de39 --- /dev/null +++ b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extractReferences } from '../../common/migrations/references'; +import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; +import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; +import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; +import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; +import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; +import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; +import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; +import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; +import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; +import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; +import { moveAttribution } from '../../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the saved object migration registry. + */ +export const savedObjectMigrations = { + '7.2.0': (doc) => { + const { attributes, references } = extractReferences(doc); + + return { + ...doc, + attributes, + references, + }; + }, + '7.4.0': (doc) => { + const attributes = emsRasterTileToEmsVectorTile(doc); + + return { + ...doc, + attributes, + }; + }, + '7.5.0': (doc) => { + const attributes = topHitsTimeToSort(doc); + + return { + ...doc, + attributes, + }; + }, + '7.6.0': (doc) => { + const attributesPhase1 = moveApplyGlobalQueryToSources(doc); + const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.7.0': (doc) => { + const attributesPhase1 = migrateSymbolStyleDescriptor(doc); + const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.8.0': (doc) => { + const attributes = migrateJoinAggKey(doc); + + return { + ...doc, + attributes, + }; + }, + '7.9.0': (doc) => { + const attributes = removeBoundsFromSavedObject(doc); + + return { + ...doc, + attributes, + }; + }, + '7.10.0': (doc) => { + const attributes = setDefaultAutoFitToBounds(doc); + + return { + ...doc, + attributes, + }; + }, + '7.12.0': (doc) => { + const attributes = addTypeToTermJoin(doc); + + return { + ...doc, + attributes, + }; + }, + '7.14.0': (doc) => { + const attributes = moveAttribution(doc); + + return { + ...doc, + attributes, + }; + }, +}; diff --git a/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts new file mode 100644 index 00000000000000..64250d0b3c5aea --- /dev/null +++ b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ESGlobPatterns } from './es_glob_patterns'; + +const testIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const noSystemIndices = [ + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const onlySystemIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +const kibanaNoTaskIndices = [ + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +describe('ES glob index patterns', () => { + it('should exclude system/internal indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('-.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(noSystemIndices); + }); + + it('should only show ".index" system indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(onlySystemIndices); + }); + + it('should only show ".kibana*" indices without _task_', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.kibana*,-*_task_*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(kibanaNoTaskIndices); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index db318d7962beb9..a6a101bc42afa4 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -42,7 +42,7 @@ export class LargeShardSizeAlert extends BaseAlert { id: ALERT_LARGE_SHARD_SIZE, name: ALERT_DETAILS[ALERT_LARGE_SHARD_SIZE].label, throttle: '12h', - defaultParams: { indexPattern: '*', threshold: 55 }, + defaultParams: { indexPattern: '-.*', threshold: 55 }, actionVariables: [ { name: 'shardIndices', diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index e1da45ab7d9915..aab3f0101ef839 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -120,11 +120,7 @@ export async function fetchIndexShardSize( for (const indexBucket of indexBuckets) { const shardIndex = indexBucket.key; const topHit = indexBucket.hits?.hits?.hits[0] as TopHitType; - if ( - !topHit || - shardIndex.charAt() === '.' || - !ESGlobPatterns.isValid(shardIndex, validIndexPatterns) - ) { + if (!topHit || !ESGlobPatterns.isValid(shardIndex, validIndexPatterns)) { continue; } const { diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c85778f2f38fad..f4cf85e0252378 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,23 +15,25 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; -export const HOST_METADATA_LIST_API = '/api/endpoint/metadata'; -export const HOST_METADATA_GET_API = '/api/endpoint/metadata/{id}'; +export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; +export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; +export const HOST_METADATA_GET_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata/{id}`; -export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; +export const TRUSTED_APPS_GET_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_LIST_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_CREATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_UPDATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_DELETE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_SUMMARY_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/summary`; -export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; -export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; +export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_response`; +export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`; export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ -export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; -export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; +export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; +export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; +export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 32affddf462949..09776b57ed8eaf 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -28,3 +28,12 @@ export const EndpointActionLogRequestSchema = { agent_id: schema.string(), }), }; + +export const ActionStatusRequestSchema = { + query: schema.object({ + agent_ids: schema.oneOf([ + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }), + schema.string({ minLength: 1 }), + ]), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fcfda9c9a30d94..3c9be9a823c498 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,11 @@ import { HostIsolationRequestSchema } from '../schema/actions'; export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export interface EndpointActionData { + command: ISOLATION_ACTIONS; + comment?: string; +} + export interface EndpointAction { action_id: string; '@timestamp': string; @@ -18,10 +23,7 @@ export interface EndpointAction { input_type: 'endpoint'; agents: string[]; user_id: string; - data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + data: EndpointActionData; } export interface EndpointActionResponse { @@ -32,11 +34,8 @@ export interface EndpointActionResponse { agent_id: string; started_at: string; completed_at: string; - error: string; - action_data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + error?: string; + action_data: EndpointActionData; } export type HostIsolationRequestBody = TypeOf; @@ -44,3 +43,10 @@ export type HostIsolationRequestBody = TypeOf( }) => { return ( <> - {isolateAction === 'isolateHost' ? ( - - {additionalInfo} - - ) : ( - - {additionalInfo} - - )} - + + {additionalInfo} + RenderResult; @@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType => { return '/app/fleet'; case APP_ID: return '/app/security'; + case MANAGEMENT_APP_ID: + return '/app/security/administration'; default: return `${appId} not mocked!`; } diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index 9d12efca19aed0..2df16fc1e21b0a 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -13,7 +13,7 @@ import type { HttpHandler, HttpStart, } from 'kibana/public'; -import { extend } from 'lodash'; +import { merge } from 'lodash'; import { act } from '@testing-library/react'; class ApiRouteNotMocked extends Error {} @@ -159,6 +159,11 @@ export const httpHandlerMockFactory = ['responseProvider'] = mocks.reduce( (providers, routeMock) => { // FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']` @@ -195,7 +200,7 @@ export const httpHandlerMockFactory = { const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0]; - const routeMock = methodMocks.find((handler) => handler.path === path); + const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path)); if (routeMock) { markApiCallAsHandled(responseProvider[routeMock.id].mockDelay); @@ -211,6 +216,9 @@ export const httpHandlerMockFactory = { + // No path params - pattern is single path + if (pathPattern === path) { + return true; + } + + // If pathPattern has params (`{value}`), then see if `path` matches it + if (/{.*?}/.test(pathPattern)) { + const pathParts = path.split(/\//); + const patternParts = pathPattern.split(/\//); + + if (pathParts.length !== patternParts.length) { + return false; + } + + return pathParts.every((part, index) => { + return part === patternParts[index] || /{.*?}/.test(patternParts[index]); + }); + } + + return false; +}; + const isHttpFetchOptionsWithPath = ( opt: string | HttpFetchOptions | HttpFetchOptionsWithPath ): opt is HttpFetchOptionsWithPath => { @@ -235,12 +266,14 @@ const isHttpFetchOptionsWithPath = ( * @example * import { composeApiHandlerMocks } from './http_handler_mock_factory'; * import { + * FleetSetupApiMockInterface, * fleetSetupApiMock, + * AgentsSetupApiMockInterface, * agentsSetupApiMock, * } from './setup'; * - * // Create the new interface as an intersection of all other Api Handler Mocks - * type ComposedApiHandlerMocks = ReturnType & ReturnType + * // Create the new interface as an intersection of all other Api Handler Mock's interfaces + * type ComposedApiHandlerMocks = AgentsSetupApiMockInterface & FleetSetupApiMockInterface * * const newComposedHandlerMock = composeApiHandlerMocks< * ComposedApiHandlerMocks @@ -267,7 +300,7 @@ export const composeHttpHandlerMocks = < handlerMocks.forEach((handlerMock) => { const { waitForApi, ...otherInterfaceProps } = handlerMock(http); - extend(mockedApiInterfaces, otherInterfaceProps); + merge(mockedApiInterfaces, otherInterfaceProps); }); return mockedApiInterfaces; diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index 7616dfccddaff7..21c8e6c15f8263 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -89,7 +89,9 @@ export const createSpyMiddleware = < type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used - const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + const err = new Error( + `Timeout! Action '${actionType}' was not dispatched within the allocated time` + ); return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { @@ -108,7 +110,10 @@ export const createSpyMiddleware = < const timeout = setTimeout(() => { watchers.delete(watch); reject(err); + // TODO: is there a way we can grab the current timeout value from jest? + // For now, this is using the default value (5000ms) - 500. }, 4500); + watchers.add(watch); }); }, diff --git a/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts new file mode 100644 index 00000000000000..c5ff39d228d265 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolvePathVariables } from './resolve_path_variables'; + +describe('utils', () => { + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts new file mode 100644 index 00000000000000..89067e575665dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 7f470c199d550f..178ae3b0f716e8 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -7,6 +7,8 @@ import { isEmpty } from 'lodash/fp'; +export * from './is_endpoint_host_isolated'; + const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; export const isUrlInvalid = (url: string | null | undefined) => { diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts new file mode 100644 index 00000000000000..2e96d56c3625f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostMetadata } from '../../../../common/endpoint/types'; +import { isEndpointHostIsolated } from './is_endpoint_host_isolated'; + +describe('When using isEndpointHostIsolated()', () => { + const generator = new EndpointDocGenerator(); + + const generateMetadataDoc = (isolation: boolean = true) => { + const metadataDoc = generator.generateHostMetadata() as HostMetadata; + return { + ...metadataDoc, + Endpoint: { + ...metadataDoc.Endpoint, + state: { + ...metadataDoc.Endpoint.state, + isolation, + }, + }, + }; + }; + + it('Returns `true` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc())).toBe(true); + }); + + it('Returns `false` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc(false))).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts new file mode 100644 index 00000000000000..6ca187c52475e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HostMetadata } from '../../../../common/endpoint/types'; + +/** + * Given an endpoint host metadata record (`HostMetadata`), this utility will validate if + * that host is isolated + * @param endpointMetadata + */ +export const isEndpointHostIsolated = (endpointMetadata: HostMetadata): boolean => { + return Boolean(endpointMetadata.Endpoint.state.isolation); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index bb1585b5392bd5..2ca84168414979 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -36,12 +36,6 @@ export const HostIsolationPanel = React.memo( return findHostName ? findHostName[0] : ''; }, [details]); - const alertRule = useMemo(() => { - const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details) - ?.values; - return findAlertRule ? findAlertRule[0] : ''; - }, [details]); - const alertId = useMemo(() => { const findAlertId = find({ category: '_id', field: '_id' }, details)?.values; return findAlertId ? findAlertId[0] : ''; @@ -95,7 +89,6 @@ export const HostIsolationPanel = React.memo( void; @@ -80,15 +78,9 @@ export const IsolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const IsolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isIsolated ? hostIsolatedSuccess : hostNotIsolated; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 449a09b932cd31..98b74817cabb67 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -25,7 +25,8 @@ export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', { - defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ', + defaultMessage: + '{caseCount} {caseCount, plural, one {case} other {cases}} associated with this host', values: { caseCount }, } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 74149f2a692d3c..e72a0d2de61bc7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -20,14 +20,12 @@ export const UnisolateHost = React.memo( ({ agentId, hostName, - alertRule, cases, caseIds, cancelCallback, }: { agentId: string; hostName: string; - alertRule: string; cases: ReactNode; caseIds: string[]; cancelCallback: () => void; @@ -80,15 +78,9 @@ export const UnisolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const UnisolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isUnIsolated ? hostUnisolatedSuccess : hostNotUnisolated; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index a7bd42c6af5ee5..cd596ef76ce0ac 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -14,7 +14,7 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; -import { HOST_METADATA_GET_API } from '../../../../../common/endpoint/constants'; +import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -25,8 +25,8 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; /** * Fetch Alerts by providing a query @@ -181,6 +181,6 @@ export const getHostMetadata = async ({ agentId: string; }): Promise => KibanaServices.get().http.fetch( - resolvePathVariables(HOST_METADATA_GET_API, { id: agentId }), + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'get' } ); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index adc6d3a6b054b8..f7894d47642755 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -11,6 +11,7 @@ import { Maybe } from '../../../../../../observability/common/typings'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getHostMetadata } from './api'; import { ISOLATION_STATUS_FAILURE } from './translations'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; interface HostIsolationStatusResponse { loading: boolean; @@ -36,7 +37,7 @@ export const useHostIsolationStatus = ({ try { const metadataResponse = await getHostMetadata({ agentId }); if (isMounted) { - setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation)); + setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata)); } } catch (error) { addError(error.message, { title: ISOLATION_STATUS_FAILURE }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5bafecb8c4ff5b..93d0642c6b3b6f 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -66,7 +66,7 @@ export const getEndpointListPath = ( export const getEndpointDetailsPath = ( props: { - name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate'; + name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate'; } & EndpointIndexUIQueryParams & EndpointDetailsUrlProps, search?: string @@ -79,6 +79,9 @@ export const getEndpointDetailsPath = ( case 'endpointIsolate': queryParams.show = 'isolate'; break; + case 'endpointUnIsolate': + queryParams.show = 'unisolate'; + break; case 'endpointPolicyResponse': queryParams.show = 'policy_response'; break; diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 8918261b6a4365..59455ccd6bb042 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; +import { parseQueryFilterToKQL } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,39 +39,4 @@ describe('utils', () => { ); }); }); - - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index 78a95eb4d6f81e..c8cf761ccaf864 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,8 +19,3 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts new file mode 100644 index 00000000000000..3a3ad47f9f5754 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + composeHttpHandlerMocks, + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../../common/mock/endpoint/http_handler_mock_factory'; +import { + HostInfo, + HostPolicyResponse, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { + BASE_POLICY_RESPONSE_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; +import { AGENT_POLICY_API_ROUTES, GetAgentPoliciesResponse } from '../../../../../fleet/common'; + +type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ + metadataList: () => HostResultList; + metadataDetails: () => HostInfo; +}>; +export const endpointMetadataHttpMocks = httpHandlerMockFactory( + [ + { + id: 'metadataList', + path: HOST_METADATA_LIST_ROUTE, + method: 'post', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + hosts: Array.from({ length: 10 }, () => { + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }), + total: 10, + request_page_size: 10, + request_page_index: 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + { + id: 'metadataDetails', + path: HOST_METADATA_GET_ROUTE, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + ] +); + +type EndpointPolicyResponseHttpMockInterface = ResponseProvidersInterface<{ + policyResponse: () => HostPolicyResponse; +}>; +export const endpointPolicyResponseHttpMock = httpHandlerMockFactory( + [ + { + id: 'policyResponse', + path: BASE_POLICY_RESPONSE_ROUTE, + method: 'get', + handler: () => { + return new EndpointDocGenerator('seed').generatePolicyResponse(); + }, + }, + ] +); + +type FleetApisHttpMockInterface = ResponseProvidersInterface<{ + agentPolicy: () => GetAgentPoliciesResponse; +}>; +export const fleetApisHttpMock = httpHandlerMockFactory([ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, + }, +]); + +type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & + EndpointPolicyResponseHttpMockInterface & + FleetApisHttpMockInterface; +/** + * HTTP Mocks that support the Endpoint List and Details page + */ +export const endpointPageHttpMock = composeHttpHandlerMocks([ + endpointMetadataHttpMocks, + endpointPolicyResponseHttpMock, + fleetApisHttpMock, +]); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 25f2631ef46ff7..178f27caa10853 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -11,6 +11,7 @@ import { HostInfo, GetHostPolicyResponse, HostIsolationRequestBody, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; @@ -137,7 +138,10 @@ export interface ServerFailedToReturnEndpointsTotal { } export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { - payload: HostIsolationRequestBody; + payload: { + type: ISOLATION_ACTIONS; + data: HostIsolationRequestBody; + }; }; export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 04a04bc38996b8..6548d8a10ce97f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -19,6 +19,7 @@ import { HostResultList, HostIsolationResponse, EndpointAction, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -135,10 +136,13 @@ describe('endpoint list middleware', () => { describe('handling of IsolateEndpointHost action', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; - const dispatchIsolateEndpointHost = () => { + const dispatchIsolateEndpointHost = (action: ISOLATION_ACTIONS = 'isolate') => { dispatch({ type: 'endpointIsolationRequest', - payload: hostIsolationRequestBodyMock(), + payload: { + type: action, + data: hostIsolationRequestBodyMock(), + }, }); }; let isolateApiResponseHandlers: ReturnType; @@ -161,7 +165,24 @@ describe('endpoint list middleware', () => { it('should call isolate api', async () => { dispatchIsolateEndpointHost(); - expect(fakeHttpServices.post).toHaveBeenCalled(); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.isolateHost).toHaveBeenCalled(); + }); + + it('should call unisolate api', async () => { + dispatchIsolateEndpointHost('unisolate'); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.unIsolateHost).toHaveBeenCalled(); }); it('should set Isolation state to loaded if api is successful', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 90427d5003384f..b62663bd787503 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -41,19 +41,19 @@ import { import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { ENDPOINT_ACTION_LOG_ROUTE, - HOST_METADATA_GET_API, - HOST_METADATA_LIST_API, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, createLoadingResourceState, } from '../../../state'; -import { isolateHost } from '../../../../common/lib/host_isolation'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -104,7 +104,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(HOST_METADATA_LIST_API, { + endpointResponse = await coreStart.http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], filters: { kql: decodedQuery.query }, @@ -253,7 +253,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory( - resolvePathVariables(HOST_METADATA_GET_API, { id: selectedEndpoint as string }) + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: selectedEndpoint as string }) ); dispatch({ type: 'serverReturnedEndpointDetails', @@ -458,7 +458,7 @@ const getAgentAndPoliciesForEndpointsList = async ( const endpointsTotal = async (http: HttpStart): Promise => { try { return ( - await http.post(HOST_METADATA_LIST_API, { + await http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: 0 }, { page_size: 1 }], }), @@ -504,7 +504,13 @@ const handleIsolateEndpointHost = async ( try { // Cast needed below due to the value of payload being `Immutable<>` - const response = await isolateHost(action.payload as HostIsolationRequestBody); + let response: HostIsolationResponse; + + if (action.payload.type === 'unisolate') { + response = await unIsolateHost(action.payload.data as HostIsolationRequestBody); + } else { + response = await isolateHost(action.payload.data as HostIsolationRequestBody); + } dispatch({ type: 'endpointIsolationRequestStateChange', 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 8b6599611ffc40..f3848557567ec8 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 @@ -32,6 +32,7 @@ import { isLoadingResourceState, } from '../../../state'; import { ServerApiError } from '../../../../common/types'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; export const listData = (state: Immutable) => state.hosts; @@ -204,6 +205,14 @@ export const uiQueryParams: ( 'admin_query', ]; + const allowedShowValues: Array = [ + 'policy_response', + 'details', + 'isolate', + 'unisolate', + 'activity_log', + ]; + for (const key of keys) { const value: string | undefined = typeof query[key] === 'string' @@ -214,13 +223,8 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if ( - value === 'policy_response' || - value === 'details' || - value === 'activity_log' || - value === 'isolate' - ) { - data[key] = value; + if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) { + data[key] = value as EndpointIndexUIQueryParams['show']; } } else { data[key] = value; @@ -378,3 +382,7 @@ export const getActivityLogError: ( return activityLog.error; } }); + +export const getIsEndpointHostIsolated = createSelector(detailsData, (details) => { + return (details && isEndpointHostIsolated(details)) || false; +}); 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 ac06f98004f597..53ddfaee7aa053 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 @@ -114,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx new file mode 100644 index 00000000000000..ac1b83bdc493bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; +import { NavigateToAppOptions } from 'kibana/public'; +import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; + +export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; +} + +/** + * Just like `EuiContextMenuItem`, but allows for additional props to be defined which will + * allow navigation to a URL path via React Router + */ +export const ContextMenuItemNavByRouter = memo( + ({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { + ...navigateOptions, + onClick, + }); + + return ( + + {children} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx index 8110c5f16a8923..94303c43cd4da3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx @@ -9,37 +9,31 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { EuiButtonIcon, EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItemProps, EuiContextMenuPanelProps, - EuiContextMenuItem, + EuiPopover, EuiPopoverProps, } from '@elastic/eui'; -import { NavigateToAppOptions } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { ContextMenuItemNavByRouter } from './context_menu_item_nav_by_rotuer'; +import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { useEndpointActionItems } from '../hooks'; export interface TableRowActionProps { - items: Array< - Omit & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - key: string; - } - >; + endpointMetadata: HostMetadata; } -export const TableRowActions = memo(({ items }) => { +export const TableRowActions = memo(({ endpointMetadata }) => { const [isOpen, setIsOpen] = useState(false); + const endpointActions = useEndpointActionItems(endpointMetadata); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => { - return items.map((itemProps) => { - return ; + return endpointActions.map((itemProps) => { + return ; }); - }, [handleCloseMenu, items]); + }, [handleCloseMenu, endpointActions]); const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => { return { 'data-test-subj': 'tableRowActionsMenuPanel' }; @@ -69,22 +63,4 @@ export const TableRowActions = memo(({ items }) => { }); TableRowActions.displayName = 'EndpointTableRowActions'; -const EuiContextMenuItemNavByRouter = memo< - EuiContextMenuItemProps & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - } ->(({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { - const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { - ...navigateOptions, - onClick, - }); - - return ( - - {children} - - ); -}); -EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; +ContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx new file mode 100644 index 00000000000000..7ecbad54dbbece --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { ActionsMenu } from './actions_menu'; +import React from 'react'; +import { act } from '@testing-library/react'; +import { endpointPageHttpMock } from '../../../mocks'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../../../../../common/lib/kibana'); + +describe('When using the Endpoint Details Actions Menu', () => { + let render: () => Promise>; + let coreStart: AppContextTestRender['coreStart']; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let renderResult: ReturnType; + let httpMocks: ReturnType; + + const setEndpointMetadataResponse = (isolation: boolean = false) => { + const endpointHost = httpMocks.responseProvider.metadataDetails(); + // Safe to mutate this mocked data + // @ts-ignore + endpointHost.metadata.Endpoint.state.isolation = isolation; + httpMocks.responseProvider.metadataDetails.mockReturnValue(endpointHost); + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + coreStart = mockedContext.coreStart; + waitForAction = mockedContext.middlewareSpy.waitForAction; + httpMocks = endpointPageHttpMock(mockedContext.coreStart.http); + + act(() => { + mockedContext.history.push( + '/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee' + ); + }); + + render = async () => { + renderResult = mockedContext.render(); + + await act(async () => { + await waitForAction('serverReturnedEndpointDetails'); + }); + + act(() => { + fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton')); + }); + + return renderResult; + }; + }); + + describe('and endpoint host is NOT isolated', () => { + beforeEach(() => setEndpointMetadataResponse()); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])('should display %s action', async (_, dataTestSubj) => { + await render(); + expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull(); + }); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])( + 'should navigate via kibana `navigateToApp()` when %s is clicked', + async (_, dataTestSubj) => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId(dataTestSubj)); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + } + ); + }); + + describe('and endpoint host is isolated', () => { + beforeEach(() => setEndpointMetadataResponse(true)); + + it('should display Unisolate action', async () => { + await render(); + expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); + }); + + it('should navigate via router when unisolate is clicked', async () => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId('unIsolateLink')); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx new file mode 100644 index 00000000000000..c778f4f2a08ec8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEndpointActionItems, useEndpointSelector } from '../../hooks'; +import { detailsData } from '../../../store/selectors'; +import { ContextMenuItemNavByRouter } from '../../components/context_menu_item_nav_by_rotuer'; + +export const ActionsMenu = React.memo<{}>(() => { + const endpointDetails = useEndpointSelector(detailsData); + const menuOptions = useEndpointActionItems(endpointDetails); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const takeActionItems = useMemo(() => { + return menuOptions.map((item) => { + return ; + }); + }, [closePopoverHandler, menuOptions]); + + const takeActionButton = useMemo(() => { + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + + + ); + }, [isPopoverOpen]); + + return ( + + + + ); +}); + +ActionsMenu.displayName = 'ActionMenu'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx index e299a7ec5f9730..289c1efeab0411 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { HostMetadata } from '../../../../../../../common/endpoint/types'; import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader'; import { EndpointIsolatedFormProps, EndpointIsolateForm, EndpointIsolateSuccess, + EndpointUnisolateForm, } from '../../../../../../common/components/endpoint/host_isolation'; import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding'; import { getEndpointDetailsPath } from '../../../../../common/routing'; @@ -25,18 +27,21 @@ import { getIsIsolationRequestPending, getWasIsolationRequestSuccessful, uiQueryParams, + getIsEndpointHostIsolated, } from '../../../store/selectors'; import { AppAction } from '../../../../../../common/store/actions'; -import { useToasts } from '../../../../../../common/lib/kibana'; -export const EndpointIsolateFlyoutPanel = memo<{ +/** + * Component handles both isolate and un-isolate for a given endpoint + */ +export const EndpointIsolationFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { const history = useHistory(); const dispatch = useDispatch>(); - const toast = useToasts(); const { show, ...queryParams } = useEndpointSelector(uiQueryParams); + const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated); const isPending = useEndpointSelector(getIsIsolationRequestPending); const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful); const isolateError = useEndpointSelector(getIsolationRequestError); @@ -45,6 +50,8 @@ export const EndpointIsolateFlyoutPanel = memo<{ Parameters[0] >({ comment: '' }); + const IsolationForm = isCurrentlyIsolated ? EndpointUnisolateForm : EndpointIsolateForm; + const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => { history.push( getEndpointDetailsPath({ @@ -59,11 +66,14 @@ export const EndpointIsolateFlyoutPanel = memo<{ dispatch({ type: 'endpointIsolationRequest', payload: { - endpoint_ids: [hostMeta.agent.id], - comment: formValues.comment, + type: isCurrentlyIsolated ? 'unisolate' : 'isolate', + data: { + endpoint_ids: [hostMeta.agent.id], + comment: formValues.comment, + }, }, }); - }, [dispatch, formValues.comment, hostMeta.agent.id]); + }, [dispatch, formValues.comment, hostMeta.agent.id, isCurrentlyIsolated]); const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => { setFormValues((prevState) => { @@ -74,12 +84,6 @@ export const EndpointIsolateFlyoutPanel = memo<{ }); }, []); - useEffect(() => { - if (isolateError) { - toast.addDanger(isolateError.message); - } - }, [isolateError, toast]); - return ( <> @@ -88,6 +92,7 @@ export const EndpointIsolateFlyoutPanel = memo<{ {wasSuccessful ? ( ) : ( - + + + )} ); }); -EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; +EndpointIsolationFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; 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 8d985f3a4cfe27..89c0e3e6a3e067 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 @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutFooter, EuiLoadingContent, EuiTitle, EuiText, @@ -55,10 +56,11 @@ import { } from './components/endpoint_details_tabs'; import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; -import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; +import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +import { ActionsMenu } from './components/actions_menu'; const DetailsFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -128,6 +130,9 @@ export const EndpointDetailsFlyout = memo(() => { }, ]; + const showFlyoutFooter = + show === 'details' || show === 'policy_response' || show === 'activity_log'; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -203,7 +208,15 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'policy_response' && } - {show === 'isolate' && } + {(show === 'isolate' || show === 'unisolate') && ( + + )} + + {showFlyoutFooter && ( + + + + )} )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts similarity index 89% rename from x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts rename to x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts index 5c8de0c4e0f3bc..4c00c00e50dbc2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts @@ -7,13 +7,14 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { EndpointState } from '../types'; +import { EndpointState } from '../../types'; +import { State } from '../../../../../common/store'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, -} from '../../../common/constants'; -import { State } from '../../../../common/store'; +} from '../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; + export function useEndpointSelector(selector: (state: EndpointState) => TSelected) { return useSelector(function (state: State) { return selector( @@ -38,7 +39,6 @@ export const useIngestUrl = (subpath: string): { url: string; appId: string; app }; }, [services.application, subpath]); }; - /** * Returns an object that contains Fleet app and URL information */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts new file mode 100644 index 00000000000000..a5a22b43e63d11 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './hooks'; +export * from './use_endpoint_action_items'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx new file mode 100644 index 00000000000000..dd498ffbbcacca --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MANAGEMENT_APP_ID } from '../../../../common/constants'; +import { APP_ID, SecurityPageName } from '../../../../../../common/constants'; +import { pagePathGetters } from '../../../../../../../fleet/public'; +import { getEndpointDetailsPath } from '../../../../common/routing'; +import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { useEndpointSelector } from './hooks'; +import { agentPolicies, uiQueryParams } from '../../store/selectors'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ContextMenuItemNavByRouterProps } from '../components/context_menu_item_nav_by_rotuer'; +import { isEndpointHostIsolated } from '../../../../../common/utils/validators/is_endpoint_host_isolated'; + +/** + * Returns a list (array) of actions for an individual endpoint + * @param endpointMetadata + */ +export const useEndpointActionItems = ( + endpointMetadata: MaybeImmutable | undefined +): ContextMenuItemNavByRouterProps[] => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const fleetAgentPolicies = useEndpointSelector(agentPolicies); + const allCurrentUrlParams = useEndpointSelector(uiQueryParams); + const { + services: { + application: { getUrlForApp }, + }, + } = useKibana(); + + return useMemo(() => { + if (endpointMetadata) { + const isIsolated = isEndpointHostIsolated(endpointMetadata); + const endpointId = endpointMetadata.agent.id; + const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id; + const endpointHostName = endpointMetadata.host.hostname; + const fleetAgentId = endpointMetadata.elastic.agent.id; + const { + show, + selected_endpoint: _selectedEndpoint, + ...currentUrlParams + } = allCurrentUrlParams; + const endpointIsolatePath = getEndpointDetailsPath({ + name: 'endpointIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + const endpointUnIsolatePath = getEndpointDetailsPath({ + name: 'endpointUnIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + + return [ + isIsolated + ? { + 'data-test-subj': 'unIsolateLink', + icon: 'logoSecurity', + key: 'unIsolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointUnIsolatePath, + }, + href: formatUrl(endpointUnIsolatePath), + children: ( + + ), + } + : { + 'data-test-subj': 'isolateLink', + icon: 'logoSecurity', + key: 'isolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointIsolatePath, + }, + href: formatUrl(endpointIsolatePath), + children: ( + + ), + }, + { + 'data-test-subj': 'hostLink', + icon: 'logoSecurity', + key: 'hostDetailsLink', + navigateAppId: APP_ID, + navigateOptions: { path: `hosts/${endpointHostName}` }, + href: `${getUrlForApp('securitySolution')}/hosts/${endpointHostName}`, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentConfigLink', + 'data-test-subj': 'agentPolicyLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + disabled: fleetAgentPolicies[endpointPolicyId] === undefined, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentDetailsLink', + 'data-test-subj': 'agentDetailsLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + children: ( + + ), + }, + ]; + } + + return []; + }, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, formatUrl, getUrlForApp]); +}; 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 d963682ff005d1..509bb7b4cf7111 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 @@ -522,7 +522,7 @@ describe('when on the endpoint list page', () => { const { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, - metadata: { agent, ...details }, + metadata: { agent, Endpoint, ...details }, // eslint-disable-next-line @typescript-eslint/naming-convention query_strategy_version, } = mockEndpointDetailsApiResult(); @@ -531,6 +531,13 @@ describe('when on the endpoint list page', () => { host_status, metadata: { ...details, + Endpoint: { + ...Endpoint, + state: { + ...Endpoint.state, + isolation: false, + }, + }, agent: { ...agent, id: '1', @@ -633,11 +640,10 @@ describe('when on the endpoint list page', () => { jest.clearAllMocks(); }); - it('should show the flyout', async () => { + it('should show the flyout and footer', async () => { const renderResult = await renderAndWaitForData(); - return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => { - expect(flyout).not.toBeNull(); - }); + await expect(renderResult.findByTestId('endpointDetailsFlyout')).not.toBeNull(); + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeNull(); }); it('should display policy name value as a link', async () => { @@ -743,6 +749,11 @@ describe('when on the endpoint list page', () => { ); }); + it('should show the Take Action button', async () => { + const renderResult = await renderAndWaitForData(); + expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); + }); + describe('when link to reassignment in Ingest is clicked', () => { beforeEach(async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); @@ -993,18 +1004,13 @@ describe('when on the endpoint list page', () => { }); }); - it('should show error toast if isolate fails', async () => { + it('should show error if isolate fails', async () => { isolateApiMock.responseProvider.isolateHost.mockImplementation(() => { throw new Error('oh oh. something went wrong'); }); - - // coreStart.http.post.mockReset(); - // coreStart.http.post.mockRejectedValue(new Error('oh oh. something went wrong')); await confirmIsolateAndWaitForApiResponse('failure'); - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'oh oh. something went wrong' - ); + expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull(); }); it('should reset isolation state and show form again', async () => { @@ -1031,6 +1037,10 @@ describe('when on the endpoint list page', () => { ) ).toBe(true); }); + + it('should NOT show the flyout footer', async () => { + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull(); + }); }); }); @@ -1045,9 +1055,19 @@ describe('when on the endpoint list page', () => { const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList(); hostInfo = { host_status: hosts[0].host_status, - metadata: hosts[0].metadata, + metadata: { + ...hosts[0].metadata, + Endpoint: { + ...hosts[0].metadata.Endpoint, + state: { + ...hosts[0].metadata.Endpoint.state, + isolation: false, + }, + }, + }, query_strategy_version: queryStrategyVersion, }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; const agentPolicy = generator.generateAgentPolicy(); @@ -1098,6 +1118,8 @@ describe('when on the endpoint list page', () => { expect(isolateLink.getAttribute('href')).toEqual( getEndpointDetailsPath({ name: 'endpointIsolate', + page_index: '0', + page_size: '10', selected_endpoint: hostInfo.metadata.agent.id, }) ); 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 7e5658f7b0cba0..cef6acff4e3442 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 @@ -39,11 +39,7 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { - DEFAULT_POLL_INTERVAL, - MANAGEMENT_APP_ID, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; +import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -61,7 +57,6 @@ 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'; -import { APP_ID } from '../../../../../common/constants'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { TableRowActions } from './components/table_row_actions'; @@ -120,7 +115,6 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, - agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -130,7 +124,6 @@ export const EndpointList = () => { isTransformEnabled, } = useEndpointSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const dispatch = useDispatch<(a: EndpointAction) => void>(); // cap ability to page at 10k records. (max_result_window) const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount; @@ -427,102 +420,15 @@ export const EndpointList = () => { }), actions: [ { + // eslint-disable-next-line react/display-name render: (item: HostInfo) => { - const endpointIsolatePath = getEndpointDetailsPath({ - name: 'endpointIsolate', - selected_endpoint: item.metadata.agent.id, - }); - - return ( - - ), - }, - { - 'data-test-subj': 'hostLink', - icon: 'logoSecurity', - key: 'hostDetailsLink', - navigateAppId: APP_ID, - navigateOptions: { path: `hosts/${item.metadata.host.hostname}` }, - href: `${services?.application?.getUrlForApp('securitySolution')}/hosts/${ - item.metadata.host.hostname - }`, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentConfigLink', - 'data-test-subj': 'agentPolicyLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - disabled: - agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentDetailsLink', - 'data-test-subj': 'agentDetailsLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - children: ( - - ), - }, - ]} - /> - ); + return ; }, }, ], }, ]; - }, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]); + }, [queryParams, search, formatUrl, PAD_LEFT]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 01bccc81b5063b..9d39ecd05ad8a6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -29,8 +29,8 @@ import { GetOneTrustedAppRequestParams, GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; -import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index dc0032243312f0..fac9fb1e5bf6e5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,9 +31,9 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index e95a33253034d4..3245369b56e40e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -5,5 +5,21 @@ * 2.0. */ +import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; +import { registerHostIsolationRoutes } from './isolation'; +import { registerActionStatusRoutes } from './status'; +import { registerActionAuditLogRoutes } from './audit_log'; + export * from './isolation'; -export * from './audit_log'; + +// wrap route registration + +export function registerActionRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + registerHostIsolationRoutes(router, endpointContext); + registerActionStatusRoutes(router, endpointContext); + registerActionAuditLogRoutes(router, endpointContext); +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index ad41f1678fbf6b..5ce27164dc8787 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -94,7 +94,6 @@ describe('Host Isolation', () => { }, ]) ); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); licenseEmitter = new Subject(); licenseService = new LicenseService(); licenseService.start(licenseEmitter); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 68420411284651..9dacc9767b88b9 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -117,7 +117,7 @@ export const isolationRequestHandler = function ( const actionID = uuid.v4(); let result; try { - result = await esClient.index({ + result = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { action_id: actionID, @@ -126,12 +126,12 @@ export const isolationRequestHandler = function ( type: 'INPUT_ACTION', input_type: 'endpoint', agents: agentIDs, - user_id: user?.username, + user_id: user!.username, data: { command: isolate ? 'isolate' : 'unisolate', - comment: req.body.comment, + comment: req.body.comment ?? undefined, }, - } as EndpointAction, + }, }); } catch (e) { return res.customError({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts new file mode 100644 index 00000000000000..34f7d140a78de4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-useless-constructor */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import moment from 'moment'; +import uuid from 'uuid'; +import { + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../../../../common/endpoint/types'; + +export const mockSearchResult = (results: any = []): ApiResponse => { + return { + body: { + hits: { + hits: results.map((a: any) => ({ + _source: a, + })), + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; +}; + +export class MockAction { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private user: string = ''; + private agents: string[] = []; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + + constructor() {} + + public build(): EndpointAction { + return { + action_id: this.actionID, + '@timestamp': this.ts.toISOString(), + expiration: this.ts.add(2, 'weeks').toISOString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: this.agents, + user_id: this.user, + data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public fromUser(u: string) { + this.user = u; + return this; + } + public withAgents(a: string[]) { + this.agents = a; + return this; + } + public withAgent(a: string) { + this.agents = [a]; + return this; + } + public withComment(c: string) { + this.comment = c; + return this; + } + public withAction(a: ISOLATION_ACTIONS) { + this.command = a; + return this; + } + public atTime(m: moment.Moment | Date) { + if (m instanceof Date) { + this.ts = moment(m); + } else { + this.ts = m; + } + return this; + } + public withID(id: string) { + this.actionID = id; + return this; + } +} + +export const aMockAction = (): MockAction => { + return new MockAction(); +}; + +export class MockResponse { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private started: moment.Moment = moment(); + private completed: moment.Moment = moment(); + private agent: string = ''; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + private error?: string; + + constructor() {} + + public build(): EndpointActionResponse { + return { + '@timestamp': this.ts.toISOString(), + action_id: this.actionID, + agent_id: this.agent, + started_at: this.started.toISOString(), + completed_at: this.completed.toISOString(), + error: this.error, + action_data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public forAction(id: string) { + this.actionID = id; + return this; + } + public forAgent(id: string) { + this.agent = id; + return this; + } +} + +export const aMockResponse = (actionID: string, agentID: string): MockResponse => { + return new MockResponse().forAction(actionID).forAgent(agentID); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts new file mode 100644 index 00000000000000..62e138ead7f815 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -0,0 +1,276 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import { registerActionStatusRoutes } from './status'; +import uuid from 'uuid'; +import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; + +describe('Endpoint Action Status', () => { + describe('schema', () => { + it('should require at least 1 agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({}); // no agent_ids provided + }).toThrow(); + }); + + it('should accept a single agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: uuid.v4() }); + }).not.toThrow(); + }); + + it('should accept multiple agent IDs', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: [uuid.v4(), uuid.v4()] }); + }).not.toThrow(); + }); + it('should limit the maximum number of agent IDs', () => { + const tooManyCooks = new Array(200).fill(uuid.v4()); // all the same ID string + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: tooManyCooks }); + }).toThrow(); + }); + }); + + describe('response', () => { + let endpointAppContextService: EndpointAppContextService; + + // convenience for calling the route and handler for action status + let getPendingStatus: (reqParams?: any) => Promise>; + // convenience for injecting mock responses for actions index and responses + let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void; + + beforeEach(() => { + const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + + registerActionStatusRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + + getPendingStatus = async (reqParams?: any): Promise> => { + const req = httpServerMock.createKibanaRequest(reqParams); + const mockResponse = httpServerMock.createResponseFactory(); + const [, routeHandler]: [ + RouteConfig, + RequestHandler + ] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(ACTION_STATUS_ROUTE))!; + await routeHandler( + createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()), + req, + mockResponse + ); + + return mockResponse; + }; + + havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { + esClientMock.asCurrentUser.search = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(actions.map((a) => a.build()))) + ) + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(responses.map((r) => r.build()))) + ); + }; + }); + + afterEach(() => { + endpointAppContextService.stop(); + }); + + it('should include agent IDs in the output, even if they have no actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + + it('should respond with a valid pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([aMockAction().withAgent(mockID)], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + it('should include a total count of a pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + it('should show multiple pending actions, and their counts', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 3 + ); + expect( + (response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate + ).toEqual(2); + }); + it('should calculate correct pending counts from grouped/bulked actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction() + .withAgents([mockID, 'IRRELEVANT-OTHER-AGENT', 'ANOTHER-POSSIBLE-AGENT']) + .withAction('isolate'), + aMockAction().withAgents([mockID, 'YET-ANOTHER-AGENT-ID']).withAction('isolate'), + aMockAction().withAgents(['YET-ANOTHER-AGENT-ID']).withAction('isolate'), // one WITHOUT our agent-under-test + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + + it('should exclude actions that have responses from the pending count', async () => { + const mockAgentID = 'XYZABC-000'; + const actionID = 'some-known-actionid'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockAgentID).withAction('isolate'), + aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionID), + ], + [aMockResponse(actionID, mockAgentID)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockAgentID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockAgentID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 1 + ); + }); + + it('should have accurate counts for multiple agents, bulk actions, and responses', async () => { + const agentOne = 'XYZABC-000'; + const agentTwo = 'DEADBEEF'; + const agentThree = 'IDIDIDID'; + + const actionTwoID = 'ID-TWO'; + havingActionsAndResponses( + [ + aMockAction().withAgents([agentOne, agentTwo, agentThree]).withAction('isolate'), + aMockAction() + .withAgents([agentTwo, agentThree]) + .withAction('isolate') + .withID(actionTwoID), + aMockAction().withAgents([agentThree]).withAction('isolate'), + ], + [aMockResponse(actionTwoID, agentThree)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [agentOne, agentTwo, agentThree], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(3); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentOne, + pending_actions: { + isolate: 1, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentTwo, + pending_actions: { + isolate: 2, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentThree, + pending_actions: { + isolate: 2, // present in all three actions, but second one has a response, therefore not pending + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts new file mode 100644 index 00000000000000..faaf41962a96cf --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + EndpointAction, + EndpointActionResponse, + PendingActionsResponse, +} from '../../../../common/endpoint/types'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers routes for checking status of endpoints based on pending actions + */ +export function registerActionStatusRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ACTION_STATUS_ROUTE, + validate: ActionStatusRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionStatusRequestHandler(endpointContext) + ); +} + +export const actionStatusRequestHandler = function ( + endpointContext: EndpointAppContext +): RequestHandler< + unknown, + TypeOf, + unknown, + SecuritySolutionRequestHandlerContext +> { + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const agentIDs: string[] = Array.isArray(req.query.agent_ids) + ? [...new Set(req.query.agent_ids)] + : [req.query.agent_ids]; + + // retrieve the unexpired actions for the given hosts + const recentActionResults = await esClient.search( + { + index: AGENT_ACTIONS_INDEX, + body: { + query: { + bool: { + filter: [ + { term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children + { term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions + { range: { expiration: { gte: 'now' } } }, // that have not expired yet + { terms: { agents: agentIDs } }, // for the requested agent IDs + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const pendingActions = + recentActionResults.body?.hits?.hits?.map((a): EndpointAction => a._source!) || []; + + // retrieve any responses to those action IDs from these agents + const actionIDs = pendingActions.map((a) => a.action_id); + const responseResults = await esClient.search( + { + index: '.fleet-actions-results', + body: { + query: { + bool: { + filter: [ + { terms: { action_id: actionIDs } }, // get results for these actions + { terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const actionResponses = responseResults.body?.hits?.hits?.map((a) => a._source!) || []; + + // respond with action-count per agent + const response = agentIDs.map((aid) => { + const responseIDsFromAgent = actionResponses + .filter((r) => r.agent_id === aid) + .map((r) => r.action_id); + return { + agent_id: aid, + pending_actions: pendingActions + .filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id)) + .map((a) => a.data.command) + .reduce((acc, cur) => { + if (cur in acc) { + acc[cur] += 1; + } else { + acc[cur] = 1; + } + return acc; + }, {} as PendingActionsResponse['pending_actions']), + } as PendingActionsResponse; + }); + + return res.ok({ + body: { + data: response, + }, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 44db86f85cf5f2..b4784c1ff5ed40 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -11,12 +11,14 @@ import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/en import { EndpointAppContext } from '../../types'; import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; import type { SecuritySolutionPluginRouter } from '../../../types'; +import { + BASE_ENDPOINT_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; -export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const METADATA_REQUEST_V1_ROUTE = `${BASE_ENDPOINT_ROUTE}/v1/metadata`; export const GET_METADATA_REQUEST_V1_ROUTE = `${METADATA_REQUEST_V1_ROUTE}/{id}`; -export const METADATA_REQUEST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; -export const GET_METADATA_REQUEST_ROUTE = `${METADATA_REQUEST_ROUTE}/{id}`; /* Filters that can be applied to the endpoint fetch route */ export const endpointFilters = schema.object({ @@ -82,7 +84,7 @@ export function registerEndpointRoutes( router.post( { - path: `${METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_LIST_ROUTE}`, validate: GetMetadataListRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, @@ -100,7 +102,7 @@ export function registerEndpointRoutes( router.get( { - path: `${GET_METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_GET_ROUTE}`, validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index b916ec19da17ff..e6d6879ba18450 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -26,7 +26,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; -import { registerEndpointRoutes, METADATA_REQUEST_ROUTE } from './index'; +import { registerEndpointRoutes } from './index'; import { createMockEndpointAppContextServiceStartContract, createMockPackageService, @@ -45,7 +45,10 @@ import { } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; -import { metadataTransformPrefix } from '../../../../common/endpoint/constants'; +import { + HOST_METADATA_LIST_ROUTE, + metadataTransformPrefix, +} from '../../../../common/endpoint/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { @@ -126,7 +129,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -167,7 +170,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -225,7 +228,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -273,7 +276,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -332,7 +335,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -416,7 +419,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), @@ -449,7 +452,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -490,7 +493,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -525,7 +528,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -558,7 +561,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 732ae482234213..5aa298d6789be7 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,10 +75,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { - registerHostIsolationRoutes, - registerActionAuditLogRoutes, -} from './endpoint/routes/actions'; +import { registerActionRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -293,8 +290,7 @@ export class Plugin implements IPlugin { }); }); }); + +describe('isSpecialSortField', () => { + test('detects special sort field', () => { + expect(isSpecialSortField('_score')).toBe(true); + }); + test('rejects special fields that not supported yet', () => { + expect(isSpecialSortField('_doc')).toBe(false); + }); +}); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 03e06d36f9319c..97685096a5d223 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -7,7 +7,7 @@ import { FC } from 'react'; -import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; +import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import type { AggName } from '../../../common/types/aggregations'; import type { Dictionary } from '../../../common/types/common'; @@ -43,6 +43,7 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.MURMUR3]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.NUMBER]: [ @@ -54,17 +55,78 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.SUM, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.STRING]: [ PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES._SOURCE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.UNKNOWN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], }; +export const TOP_METRICS_SORT_FIELD_TYPES = [ + KBN_FIELD_TYPES.NUMBER, + KBN_FIELD_TYPES.DATE, + KBN_FIELD_TYPES.GEO_POINT, +]; + +export const SORT_DIRECTION = { + ASC: 'asc', + DESC: 'desc', +} as const; + +export type SortDirection = typeof SORT_DIRECTION[keyof typeof SORT_DIRECTION]; + +export const SORT_MODE = { + MIN: 'min', + MAX: 'max', + AVG: 'avg', + SUM: 'sum', + MEDIAN: 'median', +} as const; + +export const NUMERIC_TYPES_OPTIONS = { + [KBN_FIELD_TYPES.NUMBER]: [ES_FIELD_TYPES.DOUBLE, ES_FIELD_TYPES.LONG], + [KBN_FIELD_TYPES.DATE]: [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS], +}; + +export type KbnNumericType = typeof KBN_FIELD_TYPES.NUMBER | typeof KBN_FIELD_TYPES.DATE; + +const SORT_NUMERIC_FIELD_TYPES = [ + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.DATE, + ES_FIELD_TYPES.DATE_NANOS, +] as const; + +export type SortNumericFieldType = typeof SORT_NUMERIC_FIELD_TYPES[number]; + +export type SortMode = typeof SORT_MODE[keyof typeof SORT_MODE]; + +export const TOP_METRICS_SPECIAL_SORT_FIELDS = { + _SCORE: '_score', +} as const; + +export const isSpecialSortField = (sortField: unknown) => { + return Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).some((v) => v === sortField); +}; + +export const isValidSortDirection = (arg: unknown): arg is SortDirection => { + return Object.values(SORT_DIRECTION).some((v) => v === arg); +}; + +export const isValidSortMode = (arg: unknown): arg is SortMode => { + return Object.values(SORT_MODE).some((v) => v === arg); +}; + +export const isValidSortNumericType = (arg: unknown): arg is SortNumericFieldType => { + return SORT_NUMERIC_FIELD_TYPES.some((v) => v === arg); +}; + /** * The maximum level of sub-aggregations */ @@ -75,6 +137,10 @@ export interface PivotAggsConfigBase { agg: PivotSupportedAggs; aggName: AggName; dropDownName: string; + /** + * Indicates if aggregation supports multiple fields + */ + isMultiField?: boolean; /** Indicates if aggregation supports sub-aggregations */ isSubAggsSupported?: boolean; /** Dictionary of the sub-aggregations */ @@ -130,7 +196,7 @@ export function getAggConfigFromEsAgg( } export interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase { - field: EsFieldName; + field: EsFieldName | EsFieldName[]; } export interface PivotAggsConfigWithExtra extends PivotAggsConfigWithUiBase { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 29e341fdaeaea9..4e70b7d7fe9b7a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -46,7 +46,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }, } = props.runtimeMappingsEditor; const { - actions: { deleteAggregation, deleteGroupBy }, + actions: { deleteAggregation, deleteGroupBy, updateAggregation }, state: { groupByList, aggList }, } = props.pivotConfig; @@ -55,6 +55,9 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = advancedRuntimeMappingsConfig === '' ? {} : JSON.parse(advancedRuntimeMappingsConfig); const previousConfig = runtimeMappings; + const isFieldDeleted = (field: string) => + previousConfig?.hasOwnProperty(field) && !nextConfig.hasOwnProperty(field); + applyRuntimeMappingsEditorChanges(); // If the user updates the name of the runtime mapping fields @@ -71,13 +74,16 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }); Object.keys(aggList).forEach((aggName) => { const agg = aggList[aggName] as PivotAggsConfigWithUiSupport; - if ( - isPivotAggConfigWithUiSupport(agg) && - agg.field !== undefined && - previousConfig?.hasOwnProperty(agg.field) && - !nextConfig.hasOwnProperty(agg.field) - ) { - deleteAggregation(aggName); + + if (isPivotAggConfigWithUiSupport(agg)) { + if (Array.isArray(agg.field)) { + const newFields = agg.field.filter((f) => !isFieldDeleted(f)); + updateAggregation(aggName, { ...agg, field: newFields }); + } else { + if (agg.field !== undefined && isFieldDeleted(agg.field)) { + deleteAggregation(aggName); + } + } } }); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 553581f58d55e1..fd11255374a517 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiCodeEditor, + EuiComboBox, EuiFieldText, EuiForm, EuiFormRow, @@ -79,7 +80,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha const [aggName, setAggName] = useState(defaultData.aggName); const [agg, setAgg] = useState(defaultData.agg); - const [field, setField] = useState( + const [field, setField] = useState( isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : '' ); @@ -148,13 +149,21 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha if (!isUnsupportedAgg) { const optionsArr = dictionaryToArray(options); + optionsArr .filter((o) => o.agg === defaultData.agg) .forEach((o) => { availableFields.push({ text: o.field }); }); + optionsArr - .filter((o) => isPivotAggsConfigWithUiSupport(defaultData) && o.field === defaultData.field) + .filter( + (o) => + isPivotAggsConfigWithUiSupport(defaultData) && + (Array.isArray(defaultData.field) + ? defaultData.field.includes(o.field as string) + : o.field === defaultData.field) + ) .forEach((o) => { availableAggs.push({ text: o.agg }); }); @@ -217,20 +226,48 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha data-test-subj="transformAggName" /> - {availableFields.length > 0 && ( - - setField(e.target.value)} - data-test-subj="transformAggField" - /> - - )} + {availableFields.length > 0 ? ( + aggConfigDef.isMultiField ? ( + + { + return { + value: v.text, + label: v.text as string, + }; + })} + selectedOptions={(typeof field === 'string' ? [field] : field).map((v) => ({ + value: v, + label: v, + }))} + onChange={(e) => { + const res = e.map((v) => v.value as string); + setField(res); + }} + isClearable={false} + data-test-subj="transformAggFields" + /> + + ) : ( + + setField(e.target.value)} + data-test-subj="transformAggField" + /> + + ) + ) : null} {availableAggs.length > 0 && ( = ({ defaultData, otherAggNames, onCha {isPivotAggsWithExtendedForm(aggConfigDef) && ( { setAggConfigDef({ ...aggConfigDef, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index fcdbac8c7ff39c..5891e8b330b949 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -44,6 +44,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, ], @@ -133,6 +134,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, { @@ -146,6 +148,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum(rt_bytes_bigger)' }, { label: 'value_count(rt_bytes_bigger)' }, { label: 'filter(rt_bytes_bigger)' }, + { label: 'top_metrics(rt_bytes_bigger)' }, ], }, ], diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts index ab69d22b1f3d7b..5d8d7cb967b658 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts @@ -12,6 +12,7 @@ import { import { PivotAggsConfigBase, PivotAggsConfigWithUiBase } from '../../../../../common/pivot_aggs'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Gets form configuration for provided aggregation type. @@ -23,6 +24,8 @@ export function getAggFormConfig( switch (agg) { case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 53350f0238bf06..39594dcbff9ae2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -15,6 +15,7 @@ import { PivotAggsConfigWithUiSupport, } from '../../../../../common'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Provides a configuration based on the aggregation type. @@ -41,6 +42,8 @@ export function getDefaultAggregationConfig( }; case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index 300626e0570ae2..b17f30d115f4a2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -141,6 +141,7 @@ export function getPivotDropdownOptions( }); return { + fields: combinedFields, groupByOptions, groupByOptionsData, aggOptions, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx new file mode 100644 index 00000000000000..0ec66a3d59a113 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiSelect, EuiButtonGroup, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { PivotAggsConfigTopMetrics, TopMetricsAggConfig } from '../types'; +import { PivotConfigurationContext } from '../../../../pivot_configuration/pivot_configuration'; +import { + isSpecialSortField, + KbnNumericType, + NUMERIC_TYPES_OPTIONS, + SORT_DIRECTION, + SORT_MODE, + SortDirection, + SortMode, + SortNumericFieldType, + TOP_METRICS_SORT_FIELD_TYPES, + TOP_METRICS_SPECIAL_SORT_FIELDS, +} from '../../../../../../../common/pivot_aggs'; + +export const TopMetricsAggForm: PivotAggsConfigTopMetrics['AggFormComponent'] = ({ + onChange, + aggConfig, +}) => { + const { + state: { fields }, + } = useContext(PivotConfigurationContext)!; + + const sortFieldOptions = fields + .filter((v) => TOP_METRICS_SORT_FIELD_TYPES.includes(v.type)) + .map(({ name }) => ({ text: name, value: name })); + + Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).forEach((v) => { + sortFieldOptions.unshift({ text: v, value: v }); + }); + sortFieldOptions.unshift({ text: '', value: '' }); + + const isSpecialFieldSelected = isSpecialSortField(aggConfig.sortField); + + const sortDirectionOptions = Object.values(SORT_DIRECTION).map((v) => ({ + id: v, + label: v, + })); + + const sortModeOptions = Object.values(SORT_MODE).map((v) => ({ + id: v, + label: v, + })); + + const sortFieldType = fields.find((f) => f.name === aggConfig.sortField)?.type; + + const sortSettings = aggConfig.sortSettings ?? {}; + + const updateSortSettings = useCallback( + (update: Partial) => { + onChange({ + ...aggConfig, + sortSettings: { + ...(aggConfig.sortSettings ?? {}), + ...update, + }, + }); + }, + [aggConfig, onChange] + ); + + return ( + <> + + } + > + { + onChange({ ...aggConfig, sortField: e.target.value }); + }} + data-test-subj="transformSortFieldTopMetricsLabel" + /> + + + {aggConfig.sortField ? ( + <> + {isSpecialFieldSelected ? null : ( + <> + + } + > + { + updateSortSettings({ order: id as SortDirection }); + }} + color="text" + /> + + + + + + } + > + + } + helpText={ + + } + > + { + updateSortSettings({ mode: id as SortMode }); + }} + color="text" + /> + + + {sortFieldType && NUMERIC_TYPES_OPTIONS.hasOwnProperty(sortFieldType) ? ( + + } + > + ({ + text: v, + name: v, + }))} + value={sortSettings.numericType} + onChange={(e) => { + updateSortSettings({ + numericType: e.target.value as SortNumericFieldType, + }); + }} + data-test-subj="transformSortNumericTypeTopMetricsLabel" + /> + + ) : null} + + + )} + + ) : null} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts new file mode 100644 index 00000000000000..ef57e6d1295c1a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTopMetricsAggConfig } from './config'; +import { PivotAggsConfigTopMetrics } from './types'; + +describe('top metrics agg config', () => { + let config: PivotAggsConfigTopMetrics; + + beforeEach(() => { + config = getTopMetricsAggConfig({ + agg: 'top_metrics', + aggName: 'test-agg', + field: ['test-field'], + dropDownName: 'test-agg', + }); + }); + + describe('#setUiConfigFromEs', () => { + test('sets config with a special field', () => { + // act + config.setUiConfigFromEs({ + metrics: { + field: 'test-field-01', + }, + sort: '_score', + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: '_score', + }); + }); + + test('sets config with a simple sort direction definition', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + { + field: 'test-field-02', + }, + ], + sort: { + 'sort-field': 'asc', + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01', 'test-field-02']); + expect(config.aggConfig).toEqual({ + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }); + }); + + test('sets config with a sort definition params not supported by the UI', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + ], + sort: { + 'offer.price': { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: 'offer.price', + sortSettings: { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }); + }); + }); + + describe('#getEsAggConfig', () => { + test('rejects invalid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('rejects invalid config with missing sort direction', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('converts valid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': 'asc', + }, + }); + }); + + test('preserves unsupported config', () => { + // arrange + config.field = ['field-01', 'field-02']; + + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + // @ts-ignore + nested: { + path: 'order', + }, + }, + // @ts-ignore + size: 2, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': { + order: 'asc', + nested: { + path: 'order', + }, + }, + }, + size: 2, + }); + }); + + test('converts configs with a special sorting field', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: '_score', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: '_score', + }); + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts new file mode 100644 index 00000000000000..56d17e7973e160 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + isPivotAggsConfigWithUiSupport, + isSpecialSortField, + isValidSortDirection, + isValidSortMode, + isValidSortNumericType, + PivotAggsConfigBase, + PivotAggsConfigWithUiBase, +} from '../../../../../../common/pivot_aggs'; +import { PivotAggsConfigTopMetrics } from './types'; +import { TopMetricsAggForm } from './components/top_metrics_agg_form'; +import { isPopulatedObject } from '../../../../../../../../common/shared_imports'; + +/** + * Gets initial basic configuration of the top_metrics aggregation. + */ +export function getTopMetricsAggConfig( + commonConfig: PivotAggsConfigWithUiBase | PivotAggsConfigBase +): PivotAggsConfigTopMetrics { + return { + ...commonConfig, + isSubAggsSupported: false, + isMultiField: true, + field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', + AggFormComponent: TopMetricsAggForm, + aggConfig: {}, + getEsAggConfig() { + // ensure the configuration has been completed + if (!this.isValid()) { + return null; + } + + const { sortField, sortSettings = {}, ...unsupportedConfig } = this.aggConfig; + + let sort = null; + + if (isSpecialSortField(sortField)) { + sort = sortField; + } else { + const { mode, numericType, order, ...rest } = sortSettings; + + if (mode || numericType || isPopulatedObject(rest)) { + sort = { + [sortField!]: { + ...rest, + order, + ...(mode ? { mode } : {}), + ...(numericType ? { numeric_type: numericType } : {}), + }, + }; + } else { + sort = { [sortField!]: sortSettings.order }; + } + } + + return { + metrics: (Array.isArray(this.field) ? this.field : [this.field]).map((f) => ({ field: f })), + sort, + ...(unsupportedConfig ?? {}), + }; + }, + setUiConfigFromEs(esAggDefinition) { + const { metrics, sort, ...unsupportedConfig } = esAggDefinition; + + this.field = (Array.isArray(metrics) ? metrics : [metrics]).map((v) => v.field); + + if (isSpecialSortField(sort)) { + this.aggConfig.sortField = sort; + return; + } + + const sortField = Object.keys(sort)[0]; + + this.aggConfig.sortField = sortField; + + const sortDefinition = sort[sortField]; + + this.aggConfig.sortSettings = this.aggConfig.sortSettings ?? {}; + + if (isValidSortDirection(sortDefinition)) { + this.aggConfig.sortSettings.order = sortDefinition; + } + + if (isPopulatedObject(sortDefinition)) { + const { order, mode, numeric_type: numType, ...rest } = sortDefinition; + this.aggConfig.sortSettings = rest; + + if (isValidSortDirection(order)) { + this.aggConfig.sortSettings.order = order; + } + if (isValidSortMode(mode)) { + this.aggConfig.sortSettings.mode = mode; + } + if (isValidSortNumericType(numType)) { + this.aggConfig.sortSettings.numericType = numType; + } + } + + this.aggConfig = { + ...this.aggConfig, + ...(unsupportedConfig ?? {}), + }; + }, + isValid() { + return ( + !!this.aggConfig.sortField && + (isSpecialSortField(this.aggConfig.sortField) ? true : !!this.aggConfig.sortSettings?.order) + ); + }, + }; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts new file mode 100644 index 00000000000000..a90ee5307a18ec --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PivotAggsConfigWithExtra, + SortDirection, + SortMode, + SortNumericFieldType, +} from '../../../../../../common/pivot_aggs'; + +export interface TopMetricsAggConfig { + sortField: string; + sortSettings?: { + order?: SortDirection; + mode?: SortMode; + numericType?: SortNumericFieldType; + }; +} + +export type PivotAggsConfigTopMetrics = PivotAggsConfigWithExtra; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 4bd8f5cea60926..0c31b4fe2da819 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -97,7 +97,7 @@ export const usePivotConfig = ( ) => { const toastNotifications = useToastNotifications(); - const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData } = useMemo( + const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), [defaults.runtimeMappings, indexPattern] ); @@ -347,6 +347,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, }, }; }, [ @@ -361,6 +362,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, ]); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c2cad05ff9e304..982cf768db0786 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5400,22 +5400,9 @@ "xpack.apm.apply.label": "適用", "xpack.apm.applyFilter": "{title} フィルターを適用", "xpack.apm.applyOptions": "オプションを適用", - "xpack.apm.breadcrumb.errorsTitle": "エラー", - "xpack.apm.breadcrumb.listSettingsTitle": "設定", - "xpack.apm.breadcrumb.metricsTitle": "メトリック", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概要", "xpack.apm.breadcrumb.serviceMapTitle": "サービスマップ", - "xpack.apm.breadcrumb.serviceProfilingTitle": "プロファイリング", "xpack.apm.breadcrumb.servicesTitle": "サービス", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "エージェントの編集", - "xpack.apm.breadcrumb.settings.anomalyDetection": "異常検知", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "エージェント構成の作成", - "xpack.apm.breadcrumb.settings.customizeUI": "UI をカスタマイズ", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "エージェント構成の編集", - "xpack.apm.breadcrumb.settings.indicesTitle": "インデックス", "xpack.apm.breadcrumb.tracesTitle": "トレース", - "xpack.apm.breadcrumb.transactionsTitle": "トランザクション", "xpack.apm.chart.annotation.version": "バージョン", "xpack.apm.chart.cpuSeries.processAverageLabel": "プロセス平均", "xpack.apm.chart.cpuSeries.processMaxLabel": "プロセス最大", @@ -5524,8 +5511,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", - "xpack.apm.home.servicesTabLabel": "サービス", - "xpack.apm.home.tracesTabLabel": "トレース", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", "xpack.apm.instancesLatencyDistributionChartTitle": "インスタンスのレイテンシ分布", @@ -5589,7 +5574,6 @@ "xpack.apm.profiling.highlightFrames": "検索", "xpack.apm.profiling.table.name": "名前", "xpack.apm.profiling.table.value": "自己", - "xpack.apm.profilingOverviewTitle": "プロファイリング", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "利用可能なデータがありません", "xpack.apm.propertiesTable.agentFeature.noResultFound": "\"{value}\"に対する結果が見つかりませんでした。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "例外のスタックトレース", @@ -5654,7 +5638,6 @@ "xpack.apm.selectPlaceholder": "オプションを選択:", "xpack.apm.serviceDetails.errorsTabLabel": "エラー", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用状況", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "エラーのオカレンス", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "システムメモリー使用状況", "xpack.apm.serviceDetails.metricsTabLabel": "メトリック", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5891,7 +5874,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "インデックス", "xpack.apm.settings.pageTitle": "設定", - "xpack.apm.settings.returnLinkLabel": "インベントリに戻る", "xpack.apm.settingsLinkLabel": "設定", "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.significanTerms.license.text": "相関関係APIを使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。", @@ -20331,9 +20313,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "アプリを初期化できませんでした", "xpack.securitySolution.endpoint.list.actionmenu": "開く", "xpack.securitySolution.endpoint.list.actions": "アクション", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "エージェント詳細を表示", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "エージェントポリシーを表示", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "ホスト詳細を表示", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "エンドポイントを登録しています。進行状況を追跡するには、{agentsLink}してください。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "エージェントを表示", "xpack.securitySolution.endpoint.list.endpointVersion": "バージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d3a3d9ae30c378..46f08cbed6c8e7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5429,22 +5429,9 @@ "xpack.apm.apply.label": "应用", "xpack.apm.applyFilter": "应用 {title} 筛选", "xpack.apm.applyOptions": "应用选项", - "xpack.apm.breadcrumb.errorsTitle": "错误", - "xpack.apm.breadcrumb.listSettingsTitle": "设置", - "xpack.apm.breadcrumb.metricsTitle": "指标", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概览", "xpack.apm.breadcrumb.serviceMapTitle": "服务地图", - "xpack.apm.breadcrumb.serviceProfilingTitle": "分析", "xpack.apm.breadcrumb.servicesTitle": "服务", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "代理配置", - "xpack.apm.breadcrumb.settings.anomalyDetection": "异常检测", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "创建代理配置", - "xpack.apm.breadcrumb.settings.customizeUI": "定制 UI", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "编辑代理配置", - "xpack.apm.breadcrumb.settings.indicesTitle": "索引", "xpack.apm.breadcrumb.tracesTitle": "追溯", - "xpack.apm.breadcrumb.transactionsTitle": "事务", "xpack.apm.chart.annotation.version": "版本", "xpack.apm.chart.cpuSeries.processAverageLabel": "进程平均值", "xpack.apm.chart.cpuSeries.processMaxLabel": "进程最大值", @@ -5554,8 +5541,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", - "xpack.apm.home.servicesTabLabel": "服务", - "xpack.apm.home.tracesTabLabel": "追溯", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", "xpack.apm.instancesLatencyDistributionChartTitle": "实例延迟分布", @@ -5622,7 +5607,6 @@ "xpack.apm.profiling.highlightFrames": "搜索", "xpack.apm.profiling.table.name": "名称", "xpack.apm.profiling.table.value": "自我", - "xpack.apm.profilingOverviewTitle": "分析", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "没有可用数据", "xpack.apm.propertiesTable.agentFeature.noResultFound": "没有“{value}”的结果。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "异常堆栈跟踪", @@ -5687,7 +5671,6 @@ "xpack.apm.selectPlaceholder": "选择选项:", "xpack.apm.serviceDetails.errorsTabLabel": "错误", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "错误发生次数", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "系统内存使用", "xpack.apm.serviceDetails.metricsTabLabel": "指标", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5925,7 +5908,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "索引", "xpack.apm.settings.pageTitle": "设置", - "xpack.apm.settings.returnLinkLabel": "返回库存", "xpack.apm.settingsLinkLabel": "设置", "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.significanTerms.license.text": "要使用相关性 API,必须订阅 Elastic 白金级许可证。", @@ -20631,9 +20613,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "应用无法初始化", "xpack.securitySolution.endpoint.list.actionmenu": "未结", "xpack.securitySolution.endpoint.list.actions": "操作", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "查看代理详情", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "查看代理策略", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "查看主机详情", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "正在注册终端。{agentsLink}以跟踪进度。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "查看代理", "xpack.securitySolution.endpoint.list.endpointVersion": "版本", diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index 31189428fda18f..faeb0e4a40abd4 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -17,7 +17,7 @@ import { } from 'src/core/public/mocks'; import { HttpSetup } from 'src/core/public'; -import { mockKibanaSemverVersion, UA_READONLY_MODE } from '../../../common/constants'; +import { mockKibanaSemverVersion } from '../../../common/constants'; import { AppContextProvider } from '../../../public/application/app_context'; import { apiService } from '../../../public/application/lib/api'; import { breadcrumbService } from '../../../public/application/lib/breadcrumbs'; @@ -40,7 +40,7 @@ export const WithAppDependencies = (Comp: any, overrides: Record; diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index 29253d3c373d6c..bab3d8c3fda866 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -14,13 +14,6 @@ import SemVer from 'semver/classes/semver'; export const mockKibanaVersion = '8.0.0'; export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); -/* - * This will be set to true up until the last minor before the next major. - * In readonly mode, the user will not be able to perform any actions in the UI - * and will be presented with a message indicating as such. - */ -export const UA_READONLY_MODE = true; - /* * Map of 7.0 --> 8.0 index setting deprecation log messages and associated settings * We currently only support one setting deprecation (translog retention), but the code is written diff --git a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts index b17c1301f83f30..73e5d33e6c968e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts @@ -7,7 +7,6 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; -import { UA_READONLY_MODE } from '../../common/constants'; import { renderApp } from './render_app'; import { KibanaVersionContext } from './app_context'; import { apiService } from './lib/api'; @@ -17,7 +16,8 @@ export async function mountManagementSection( coreSetup: CoreSetup, isCloudEnabled: boolean, params: ManagementAppMountParams, - kibanaVersionInfo: KibanaVersionContext + kibanaVersionInfo: KibanaVersionContext, + readonly: boolean ) { const [ { i18n, docLinks, notifications, application, deprecations }, @@ -37,7 +37,7 @@ export async function mountManagementSection( docLinks, kibanaVersionInfo, notifications, - isReadOnlyMode: UA_READONLY_MODE, + isReadOnlyMode: readonly, history, api: apiService, breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index e33f146dd47fcf..4f5429201f3041 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -22,7 +22,7 @@ interface Dependencies { export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} setup(coreSetup: CoreSetup, { cloud, management }: Dependencies) { - const { enabled } = this.ctx.config.get(); + const { enabled, readonly } = this.ctx.config.get(); if (!enabled) { return; @@ -61,7 +61,8 @@ export class UpgradeAssistantUIPlugin implements Plugin { coreSetup, isCloudEnabled, params, - kibanaVersionInfo + kibanaVersionInfo, + readonly ); return () => { diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 15e14356724077..035a6515de1529 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -7,15 +7,16 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; import { UpgradeAssistantServerPlugin } from './plugin'; -import { configSchema } from '../common/config'; +import { configSchema, Config } from '../common/config'; export const plugin = (ctx: PluginInitializerContext) => { return new UpgradeAssistantServerPlugin(ctx); }; -export const config: PluginConfigDescriptor = { +export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { enabled: true, + readonly: true, }, }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index ebb6eb6bdc989c..229933c2f06429 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import { colourPalette, + formatTooltipHeading, getConnectingTime, getSeriesAndDomain, getSidebarItems, @@ -729,3 +730,13 @@ describe('getSidebarItems', () => { expect(actual[0].offsetIndex).toBe(1); }); }); + +describe('formatTooltipHeading', () => { + it('puts index and URL text together', () => { + expect(formatTooltipHeading(1, 'http://www.elastic.co/')).toEqual('1. http://www.elastic.co/'); + }); + + it('returns only the text if `index` is NaN', () => { + expect(formatTooltipHeading(NaN, 'http://www.elastic.co/')).toEqual('http://www.elastic.co/'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index f497bf1ea7b354..0f0ce01d250998 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -450,3 +450,6 @@ const MIME_TYPE_PALETTE = buildMimeTypePalette(); type ColourPalette = TimingColourPalette & MimeTypeColourPalette; export const colourPalette: ColourPalette = { ...TIMING_PALETTE, ...MIME_TYPE_PALETTE }; + +export const formatTooltipHeading = (index: number, fullText: string): string => + isNaN(index) ? fullText : `${index}. ${fullText}`; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index a661d60400f97d..956f4b19c66263 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -8,17 +8,19 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButtonEmpty, EuiScreenReaderOnly, EuiToolTip, - EuiButtonEmpty, EuiLink, EuiText, EuiIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; import { WaterfallTooltipResponsiveMaxWidth } from './styles'; import { FIXED_AXIS_HEIGHT } from './constants'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; interface Props { index: number; @@ -116,7 +118,9 @@ export const MiddleTruncatedText = ({ + } data-test-subj="middleTruncatedTextToolTip" delay="long" position="top" diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 8e3033037766a2..f8de61f9d86903 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -153,6 +153,9 @@ export const WaterfallChartTooltip = euiStyled(WaterfallTooltipResponsiveMaxWidt border-radius: ${(props) => props.theme.eui.euiBorderRadius}; color: ${(props) => props.theme.eui.euiColorLightestShade}; padding: ${(props) => props.theme.eui.paddingSizes.s}; + .euiToolTip__arrow { + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + } `; export const NetworkRequestsTotalStyle = euiStyled(EuiText)` diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index 19a828aa097b6f..8723dd744132ae 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -18,11 +18,12 @@ import { TickFormatter, TooltipInfo, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BAR_HEIGHT } from './constants'; import { useChartTheme } from '../../../../../hooks/use_chart_theme'; import { WaterfallChartChartContainer, WaterfallChartTooltip } from './styles'; import { useWaterfallContext, WaterfallData } from '..'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; const getChartHeight = (data: WaterfallData): number => { // We get the last item x(number of bars) and adds 1 to cater for 0 index @@ -32,23 +33,25 @@ const getChartHeight = (data: WaterfallData): number => { }; const Tooltip = (tooltipInfo: TooltipInfo) => { - const { data, renderTooltipItem } = useWaterfallContext(); - const relevantItems = data.filter((item) => { - return ( - item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps - ); - }); - return relevantItems.length ? ( - - - {relevantItems.map((item, index) => { - return ( - {renderTooltipItem(item.config.tooltipProps)} - ); - })} - - - ) : null; + const { data, sidebarItems } = useWaterfallContext(); + return useMemo(() => { + const sidebarItem = sidebarItems?.find((item) => item.index === tooltipInfo.header?.value); + const relevantItems = data.filter((item) => { + return ( + item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps + ); + }); + return relevantItems.length ? ( + + {sidebarItem && ( + + )} + + ) : null; + }, [data, sidebarItems, tooltipInfo.header?.value]); }; interface Props { @@ -82,7 +85,12 @@ export const WaterfallBarChart = ({ ({ + useWaterfallContext: jest.fn().mockReturnValue({ + data: [ + { + x: 0, + config: { + url: 'https://www.elastic.co', + tooltipProps: { + colour: '#000000', + value: 'test-val', + }, + showTooltip: true, + }, + }, + { + x: 0, + config: { + url: 'https://www.elastic.co/with/missing/tooltip.props', + showTooltip: true, + }, + }, + { + x: 1, + config: { + url: 'https://www.elastic.co/someresource.path', + tooltipProps: { + colour: '#010000', + value: 'test-val-missing', + }, + showTooltip: true, + }, + }, + ], + renderTooltipItem: (props: any) => ( +
+
{props.colour}
+
{props.value}
+
+ ), + sidebarItems: [ + { + isHighlighted: true, + index: 0, + offsetIndex: 1, + url: 'https://www.elastic.co', + status: 200, + method: 'GET', + }, + ], + }), +})); + +describe('WaterfallTooltipContent', () => { + it('renders tooltip', () => { + const { getByText, queryByText } = render( + + ); + expect(getByText('#000000')).toBeInTheDocument(); + expect(getByText('test-val')).toBeInTheDocument(); + expect(getByText('1. https://www.elastic.co')).toBeInTheDocument(); + expect(queryByText('#010000')).toBeNull(); + expect(queryByText('test-val-missing')).toBeNull(); + }); + + it(`doesn't render metric if tooltip props missing`, () => { + const { getAllByLabelText, getByText } = render( + + ); + const metricElements = getAllByLabelText('tooltip item'); + expect(metricElements).toHaveLength(1); + expect(getByText('test-val')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx new file mode 100644 index 00000000000000..21b3bf72d22178 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { useWaterfallContext } from '../context/waterfall_chart'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; + +interface Props { + text: string; + url: string; +} + +const StyledText = euiStyled(EuiText)` + font-weight: bold; +`; + +const StyledHorizontalRule = euiStyled(EuiHorizontalRule)` + background-color: ${(props) => props.theme.eui.euiColorDarkShade}; +`; + +export const WaterfallTooltipContent: React.FC = ({ text, url }) => { + const { data, renderTooltipItem, sidebarItems } = useWaterfallContext(); + + const tooltipMetrics = data.filter( + (datum) => + datum.x === sidebarItems?.find((sidebarItem) => sidebarItem.url === url)?.index && + datum.config.tooltipProps && + datum.config.showTooltip + ); + return ( + <> + {text} + + + {tooltipMetrics.map((item, idx) => ( + {renderTooltipItem(item.config.tooltipProps)} + ))} + + + ); +}; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 318da3e1140979..326fb0bfac4656 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index db3cdd17a89dc1..e18f805bf174ed 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 8f40f5826c537c..a07b9666685452 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -45,6 +45,9 @@ export default function ({ getService }) { const modifiedDate = '2019-04-30T14:30:00.000Z'; policy.modified_date = modifiedDate; + // We don't want to match `_meta` field since it can change between Elasticsearch versions + delete policy.policy._meta; + expect(policy).to.eql({ version: 1, modified_date: modifiedDate, diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 109579e867cb00..fe6e1c70356b0e 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -9,41 +9,71 @@ import expect from '@kbn/expect'; export default function ({ getService }) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('migrations', () => { - it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { - const resp = await supertest - .post(`/api/saved_objects/map`) - .set('kbn-xsrf', 'kibana') - .send({ - attributes: { - title: '[Logs] Total Requests and Bytes', - layerListJSON: - '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + describe('saved object migrations', () => { + it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { + const resp = await supertest + .post(`/api/saved_objects/map`) + .set('kbn-xsrf', 'kibana') + .send({ + attributes: { + title: '[Logs] Total Requests and Bytes', + layerListJSON: + '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + }, + migrationVersion: {}, + }) + .expect(200); + + expect(resp.body.references).to.eql([ + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_0_join_0_index_pattern', + type: 'index-pattern', + }, + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_1_source_index_pattern', + type: 'index-pattern', }, - migrationVersion: {}, - }) - .expect(200); - - expect(resp.body.references).to.eql([ - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_0_join_0_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_1_source_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_2_source_index_pattern', - type: 'index-pattern', - }, - ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); - expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_2_source_index_pattern', + type: 'index-pattern', + }, + ]); + expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); + expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + }); + }); + + describe('embeddable migrations', () => { + before(async () => { + await esArchiver.loadIfNeeded('maps/kibana'); + }); + + after(async () => { + await esArchiver.unload('maps/kibana'); + }); + + it('should apply embeddable migrations', async () => { + const resp = await supertest + .get(`/api/saved_objects/dashboard/4beb0d80-c2ef-11eb-b0cb-bd162d969e6b`) + .set('kbn-xsrf', 'kibana') + .expect(200); + + let panels; + try { + panels = JSON.parse(resp.body.attributes.panelsJSON); + } catch (error) { + throw 'Unable to parse panelsJSON from dashboard saved object'; + } + expect(panels.length).to.be(1); + expect(panels[0].type).to.be('map'); + expect(panels[0].version).to.be('7.14.0'); + }); }); }); } diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index c79c4f3eaa88ec..98ef83c4378634 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index 9e4f5c8af8b05f..89587fe2596837 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 442740c7666df9..c4dc288b0e0601 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 583df6ea5ed07b..dcdcc039bc9d64 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index f08712e0156566..5830fc2d1017fa 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index b520c374d4f903..806929e67ebbcc 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index b692699182cac0..143dc123bc7229 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts new file mode 100644 index 00000000000000..1ab7b00da5d763 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from '../agents/services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +const TEST_INDEX = 'logs-log.log-test'; + +const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +let pkgKey: string; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + function indexUsingApiKey(body: any, apiKey: string): Promise<{ body: Record }> { + const supertestWithoutAuth = getService('esSupertestWithoutAuth'); + return supertestWithoutAuth + .post(`/${TEST_INDEX}/_doc`) + .set('Authorization', `ApiKey ${apiKey}`) + .send(body) + .expect(201); + } + + describe('fleet_final_pipeline', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('fleet/empty_fleet_server'); + }); + setupFleetAndAgents(providerContext); + + // Use the custom log package to test the fleet final pipeline + before(async () => { + const { body: getPackagesRes } = await supertest.get( + `/api/fleet/epm/packages?experimental=true` + ); + + const logPackage = getPackagesRes.response.find((p: any) => p.name === 'log'); + if (!logPackage) { + throw new Error('No log package'); + } + + pkgKey = `log-${logPackage.version}`; + + await supertest + .post(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + after(async () => { + await supertest + .delete(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + }); + + after(async () => { + const res = await es.search({ + index: TEST_INDEX, + }); + + for (const hit of res.body.hits.hits) { + await es.delete({ + id: hit._id, + index: hit._index, + }); + } + }); + + it('should correctly setup the final pipeline and apply to fleet managed index template', async () => { + const pipelineRes = await es.ingest.getPipeline({ id: FINAL_PIPELINE_ID }); + expect(pipelineRes.body).to.have.property(FINAL_PIPELINE_ID); + + const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); + expect(res.body.index_templates.length).to.be(1); + expect( + res.body.index_templates[0]?.index_template?.template?.settings?.index?.final_pipeline + ).to.be(FINAL_PIPELINE_ID); + }); + + it('For a doc written without api key should write the correct api key status', async () => { + const res = await es.index({ + index: 'logs-log.log-test', + body: { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + agent: { + id: 'agent1', + }, + }, + }); + + const { body: doc } = await es.get({ + id: res.body._id, + index: res.body._index, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be('no_api_key'); + expect(event).to.have.property('ingested'); + }); + + const scenarios = [ + { + name: 'API key without metadata', + expectedStatus: 'missing_metadata', + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata', + expectedStatus: 'verified', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata and no agent id in event', + expectedStatus: 'missing_metadata', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + }, + { + name: 'API key with agent id metadata and tampered agent id in event', + expectedStatus: 'agent_id_mismatch', + apiKey: { + metadata: { + agent_id: 'agent2', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + ]; + + for (const scenario of scenarios) { + it(`Should write the correct event.agent_id_status for ${scenario.name}`, async () => { + // Create an API key + const { body: apiKeyRes } = await es.security.createApiKey({ + body: { + name: `test api key`, + ...(scenario.apiKey || {}), + }, + }); + + const res = await indexUsingApiKey( + { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + ...(scenario.event || {}), + }, + Buffer.from(`${apiKeyRes.id}:${apiKeyRes.api_key}`).toString('base64') + ); + + const { body: doc } = await es.get({ + id: res.body._id as string, + index: res.body._index as string, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be(scenario.expectedStatus); + expect(event).to.have.property('ingested'); + }); + } + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 445d9706bb9a93..b6a1fd5d7346d9 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -25,5 +25,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); loadTestFile(require.resolve('./install_error_rollback')); + loadTestFile(require.resolve('./final_pipeline')); }); } diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index bdbfb5050a32f9..94a0eedd07c54f 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -31,8 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - // FLAKY: https://github.com/elastic/kibana/issues/86950 - describe.skip('dashboard feature controls security', () => { + describe('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -86,7 +85,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('only shows the dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Dashboard']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Dashboard', + 'Stack Management', + ]); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -108,8 +111,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeMissingOrFail(); }); - // Can't figure out how to get this test to pass - it.skip(`create new dashboard shows addNew button`, async () => { + it(`create new dashboard shows addNew button`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -320,8 +322,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -330,7 +331,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { @@ -347,6 +348,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + it('does not allow copy to dashboard behaviour', async () => { + await panelActions.expectMissingPanelAction('embeddablePanelAction-copyToDashboard'); + }); + it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); @@ -438,8 +443,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -448,7 +452,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index 2c151235518e08..730c00a8d5e4f1 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -29,8 +29,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const security = getService('security'); const find = getService('find'); - // flaky https://github.com/elastic/kibana/issues/98249 - describe.skip('dashboard time to visualize security', () => { + describe('dashboard time to visualize security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json index b70a9d5df0eb88..22f4afcf99d4d9 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json @@ -13,7 +13,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", @@ -81,7 +87,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", @@ -152,7 +164,13 @@ "status": "success" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", 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 4a879c20f19ab8..d0c4559d0a0a9f 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1199,6 +1199,34 @@ } } +{ + "type": "doc", + "value": { + "id": "dashboard:4beb0d80-c2ef-11eb-b0cb-bd162d969e6b", + "index": ".kibana", + "source": { + "dashboard": { + "title" : "by value map", + "hits" : 0, + "description" : "", + "panelsJSON" : "[{\"version\":\"7.12.1-SNAPSHOT\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\"},\"panelIndex\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[]\",\"mapStateJSON\":\"{\\\"zoom\\\":1.75,\\\"center\\\":{\\\"lon\\\":0,\\\"lat\\\":19.94277},\\\"timeFilters\\\":{\\\"from\\\":\\\"now-15m\\\",\\\"to\\\":\\\"now\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":0},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[]}\"},\"mapCenter\":{\"lat\":19.94277,\"lon\":0,\"zoom\":1.75},\"mapBuffer\":{\"minLon\":-211.13072,\"minLat\":-55.27145,\"maxLon\":211.13072,\"maxLat\":87.44135},\"isLayerTOCOpen\":true,\"openTOCDetails\":[],\"hiddenLayers\":[],\"enhancements\":{}}}]", + "optionsJSON" : "{\"hidePanelTitles\":false,\"useMargins\":true}", + "version" : 1, + "timeRestore" : false, + "kibanaSavedObjectMeta" : { + "searchSourceJSON" : "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + } + }, + "type" : "dashboard", + "references" : [ ], + "migrationVersion" : { + "dashboard" : "7.11.0" + }, + "updated_at" : "2021-06-01T15:37:39.198Z" + } + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 2f4575d45cc20f..8dfef36c3a1c11 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 417c9bb20f9b7f..583a37d7f4ef9a 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index 58bac6fe454179..d41dab2741cd59 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; 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 f9f518091847de..960deb692ac8dc 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 @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 37aff5a8cdadd2..9726c47a9bc0a6 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index 71621f4e3db8ab..84ab8ce42c13ef 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 8dd5adba43edbd..a1be11c4f696d1 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,15 +5,15 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, deleteAllDocsFromMetadataIndex, deleteMetadataStream, } from './data_stream_helper'; -import { METADATA_REQUEST_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; +import { HOST_METADATA_LIST_ROUTE } from '../../../plugins/security_solution/common/endpoint/constants'; /** * The number of host documents in the es archive. @@ -25,13 +25,13 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('test metadata api', () => { - describe(`POST ${METADATA_REQUEST_ROUTE} when index is empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataIndex(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe(`POST ${METADATA_REQUEST_ROUTE} when index is not empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { before(async () => { await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); // wait for transform @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return one entry for each host with default paging', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -69,7 +69,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on paging properties passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -94,7 +94,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -116,7 +116,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -134,7 +134,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -152,7 +152,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters and paging passed.', async () => { const notIncludedIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -190,7 +190,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -212,7 +212,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events for an endpoint', async () => { const targetEndpointIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -234,7 +234,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events where policy status is not success', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -278,7 +278,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index f3f86d4610d2bb..6879184b9bc13e 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 79ea93da8a5b09..73687784d15eaf 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; diff --git a/yarn.lock b/yarn.lock index 032c5255130d97..c5255bc4d0d305 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1392,7 +1392,7 @@ utility-types "^3.10.0" uuid "^3.3.2" -"@elastic/datemath@link:bazel-bin/packages/elastic-datemath/npm_module": +"@elastic/datemath@link:bazel-bin/packages/elastic-datemath": version "0.0.0" uid "" @@ -1435,7 +1435,7 @@ semver "7.3.2" topojson-client "^3.1.0" -"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module": +"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana": version "0.0.0" uid "" @@ -1572,7 +1572,7 @@ "@types/node-jose" "1.1.0" node-jose "1.1.0" -"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set/npm_module": +"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set": version "0.0.0" uid "" @@ -2591,27 +2591,27 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" -"@kbn/ace@link:bazel-bin/packages/kbn-ace/npm_module": +"@kbn/ace@link:bazel-bin/packages/kbn-ace": version "0.0.0" uid "" -"@kbn/analytics@link:bazel-bin/packages/kbn-analytics/npm_module": +"@kbn/analytics@link:bazel-bin/packages/kbn-analytics": version "0.0.0" uid "" -"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader/npm_module": +"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader": version "0.0.0" uid "" -"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils": version "0.0.0" uid "" -"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser/npm_module": +"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser": version "0.0.0" uid "" -"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset/npm_module": +"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset": version "0.0.0" uid "" @@ -2619,23 +2619,23 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema": version "0.0.0" uid "" -"@kbn/config@link:bazel-bin/packages/kbn-config/npm_module": +"@kbn/config@link:bazel-bin/packages/kbn-config": version "0.0.0" uid "" -"@kbn/crypto@link:bazel-bin/packages/kbn-crypto/npm_module": +"@kbn/crypto@link:bazel-bin/packages/kbn-crypto": version "0.0.0" uid "" -"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils": version "0.0.0" uid "" -"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils/npm_module": +"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils": version "0.0.0" uid "" @@ -2643,23 +2643,23 @@ version "0.0.0" uid "" -"@kbn/es@link:bazel-bin/packages/kbn-es/npm_module": +"@kbn/es@link:bazel-bin/packages/kbn-es": version "0.0.0" uid "" -"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module": +"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana": version "0.0.0" uid "" -"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module": +"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint": version "0.0.0" uid "" -"@kbn/expect@link:bazel-bin/packages/kbn-expect/npm_module": +"@kbn/expect@link:bazel-bin/packages/kbn-expect": version "0.0.0" uid "" -"@kbn/i18n@link:bazel-bin/packages/kbn-i18n/npm_module": +"@kbn/i18n@link:bazel-bin/packages/kbn-i18n": version "0.0.0" uid "" @@ -2667,23 +2667,23 @@ version "0.0.0" uid "" -"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils/npm_module": +"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils": version "0.0.0" uid "" -"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging/npm_module": +"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging": version "0.0.0" uid "" -"@kbn/logging@link:bazel-bin/packages/kbn-logging/npm_module": +"@kbn/logging@link:bazel-bin/packages/kbn-logging": version "0.0.0" uid "" -"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl/npm_module": +"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl": version "0.0.0" uid "" -"@kbn/monaco@link:bazel-bin/packages/kbn-monaco/npm_module": +"@kbn/monaco@link:bazel-bin/packages/kbn-monaco": version "0.0.0" uid "" @@ -2691,7 +2691,7 @@ version "0.0.0" uid "" -"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator/npm_module": +"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator": version "0.0.0" uid "" @@ -2703,51 +2703,51 @@ version "0.0.0" uid "" -"@kbn/rule-data-utils@link:packages/kbn-rule-data-utils": +"@kbn/rule-data-utils@link:bazel-bin/packages/kbn-rule-data-utils": version "0.0.0" uid "" -"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module": +"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module": +"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module": +"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module": +"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module": +"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils": version "0.0.0" uid "" -"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module": +"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api": version "0.0.0" uid "" -"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module": +"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants": version "0.0.0" uid "" -"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module": +"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks": version "0.0.0" uid "" -"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module": +"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils": version "0.0.0" uid "" -"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils/npm_module": +"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils": version "0.0.0" uid "" -"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools/npm_module": +"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools": version "0.0.0" uid "" @@ -2755,7 +2755,7 @@ version "0.0.0" uid "" -"@kbn/std@link:bazel-bin/packages/kbn-std/npm_module": +"@kbn/std@link:bazel-bin/packages/kbn-std": version "0.0.0" uid "" @@ -2763,7 +2763,7 @@ version "0.0.0" uid "" -"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools/npm_module": +"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools": version "0.0.0" uid "" @@ -2775,7 +2775,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath": version "0.0.0" uid "" @@ -2787,11 +2787,11 @@ version "0.0.0" uid "" -"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module": +"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types": version "0.0.0" uid "" -"@kbn/utils@link:bazel-bin/packages/kbn-utils/npm_module": +"@kbn/utils@link:bazel-bin/packages/kbn-utils": version "0.0.0" uid "" @@ -4953,11 +4953,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/git-url-parse@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" - integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== - "@types/glob-base@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" @@ -14372,21 +14367,6 @@ gifwrap@^0.9.2: image-q "^1.1.1" omggif "^1.0.10" -git-up@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-4.0.1.tgz#cb2ef086653640e721d2042fe3104857d89007c0" - integrity sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw== - dependencies: - is-ssh "^1.3.0" - parse-url "^5.0.0" - -git-url-parse@11.1.2: - version "11.1.2" - resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" - integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== - dependencies: - git-up "^4.0.0" - github-markdown-css@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-2.10.0.tgz#0612fed22816b33b282f37ef8def7a4ecabfe993" @@ -16546,13 +16526,6 @@ is-set@^2.0.1: resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== -is-ssh@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" - integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== - dependencies: - protocols "^1.1.0" - is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -19306,14 +19279,13 @@ mini-create-react-context@^0.4.0: "@babel/runtime" "^7.5.5" tiny-warning "^1.0.3" -mini-css-extract-plugin@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" - integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== +mini-css-extract-plugin@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.1.0.tgz#dcc2f0bfbec660c0bd1200ff7c8f82deec2cc8a6" + integrity sha512-0bTS+Fg2tGe3dFAgfiN7+YRO37oyQM7/vjFvZF1nXSCJ/sy0tGpeme8MbT4BCpUuUphKwTh9LH/uuTcWRr9DPA== dependencies: - loader-utils "^1.1.0" - normalize-url "1.9.1" - schema-utils "^1.0.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" webpack-sources "^1.1.0" minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: @@ -20260,17 +20232,7 @@ normalize-selector@^0.2.0: resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= -normalize-url@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21117,24 +21079,6 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parse-path@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-4.0.1.tgz#0ec769704949778cb3b8eda5e994c32073a1adff" - integrity sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA== - dependencies: - is-ssh "^1.3.0" - protocols "^1.4.0" - -parse-url@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" - integrity sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg== - dependencies: - is-ssh "^1.3.0" - normalize-url "^3.3.0" - parse-path "^4.0.0" - protocols "^1.4.0" - parse5@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" @@ -22277,11 +22221,6 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== -protocols@^1.1.0, protocols@^1.4.0: - version "1.4.7" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" - integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== - proxy-addr@~2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -22455,14 +22394,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - query-string@^6.13.2: version "6.13.2" resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.2.tgz#3585aa9412c957cbd358fd5eaca7466f05586dda" @@ -25242,13 +25173,6 @@ sonic-boom@^1.0.2: atomic-sleep "^1.0.0" flatstr "^1.0.12" -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= - dependencies: - is-plain-obj "^1.0.0" - sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -25793,11 +25717,6 @@ stream-to-async-iterator@^0.2.0: resolved "https://registry.yarnpkg.com/stream-to-async-iterator/-/stream-to-async-iterator-0.2.0.tgz#bef5c885e9524f98b2fa5effecc357bd58483780" integrity sha1-vvXIhelST5iy+l7/7MNXvVhIN4A= -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"