diff --git a/.eslintrc.js b/.eslintrc.js index b70090a50e64d..ab868c29b7bed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -89,6 +89,72 @@ const SAFER_LODASH_SET_DEFINITELYTYPED_HEADER = ` */ `; +/** Packages which should not be included within production code. */ +const DEV_PACKAGES = [ + 'kbn-babel-code-parser', + 'kbn-dev-utils', + 'kbn-docs-utils', + 'kbn-es*', + 'kbn-eslint*', + 'kbn-optimizer', + 'kbn-plugin-generator', + 'kbn-plugin-helpers', + 'kbn-pm', + 'kbn-storybook', + 'kbn-telemetry-tools', + 'kbn-test', +]; + +/** Directories (at any depth) which include dev-only code. */ +const DEV_DIRECTORIES = [ + '.storybook', + '__tests__', + '__test__', + '__jest__', + '__fixtures__', + '__mocks__', + '__stories__', + 'e2e', + 'fixtures', + 'ftr_e2e', + 'integration_tests', + 'manual_tests', + 'mock', + 'storybook', + 'scripts', + 'test', + 'test-d', + 'test_utils', + 'test_utilities', + 'test_helpers', + 'tests_client_integration', +]; + +/** File patterns for dev-only code. */ +const DEV_FILE_PATTERNS = [ + '*.mock.{js,ts,tsx}', + '*.test.{js,ts,tsx}', + '*.test.helpers.{js,ts,tsx}', + '*.stories.{js,ts,tsx}', + '*.story.{js,ts,tsx}', + '*.stub.{js,ts,tsx}', + 'mock.{js,ts,tsx}', + '_stubs.{js,ts,tsx}', + '{testHelpers,test_helper,test_utils}.{js,ts,tsx}', + '{postcss,webpack}.config.js', +]; + +/** Glob patterns which describe dev-only code. */ +const DEV_PATTERNS = [ + ...DEV_PACKAGES.map((pkg) => `packages/${pkg}/**/*`), + ...DEV_DIRECTORIES.map((dir) => `{packages,src,x-pack}/**/${dir}/**/*`), + ...DEV_FILE_PATTERNS.map((file) => `{packages,src,x-pack}/**/${file}`), + 'packages/kbn-interpreter/tasks/**/*', + 'src/dev/**/*', + 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*', + 'x-pack/plugins/*/server/scripts/**/*', +]; + module.exports = { root: true, @@ -491,43 +557,17 @@ module.exports = { }, /** - * Files that ARE NOT allowed to use devDependencies - */ - { - files: ['x-pack/**/*.js', 'packages/kbn-interpreter/**/*.js'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: false, - peerDependencies: true, - packageDir: '.', - }, - ], - }, - }, - - /** - * Files that ARE allowed to use devDependencies + * Single package.json rules, it tells eslint to ignore the child package.json files + * and look for dependencies declarations in the single and root level package.json */ { - files: [ - 'packages/kbn-es/src/**/*.js', - 'packages/kbn-interpreter/tasks/**/*.js', - 'packages/kbn-interpreter/src/plugin/**/*.js', - 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*.js', - 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__,public}/**/*.js', - 'x-pack/**/*.test.js', - 'x-pack/test_utils/**/*', - 'x-pack/gulpfile.js', - 'x-pack/plugins/apm/public/utils/testHelpers.js', - 'x-pack/plugins/canvas/shareable_runtime/postcss.config.js', - ], + files: ['{src,x-pack,packages}/**/*.{js,mjs,ts,tsx}'], rules: { 'import/no-extraneous-dependencies': [ 'error', { - devDependencies: true, + /* Files that ARE allowed to use devDependencies */ + devDependencies: [...DEV_PATTERNS], peerDependencies: true, packageDir: '.', }, @@ -1420,21 +1460,5 @@ module.exports = { ], }, }, - - /** - * Single package.json rules, it tells eslint to ignore the child package.json files - * and look for dependencies declarations in the single and root level package.json - */ - { - files: ['**/*.{js,mjs,ts,tsx}'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - packageDir: '.', - }, - ], - }, - }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f2f260addb35..33b3e4a7dede6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,6 +59,7 @@ /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services /x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-services /x-pack/plugins/runtime_fields @elastic/kibana-app-services +/x-pack/test/search_sessions_integration/ @elastic/kibana-app-services #CC# /src/plugins/bfetch/ @elastic/kibana-app-services #CC# /src/plugins/index_pattern_management/ @elastic/kibana-app-services #CC# /src/plugins/inspector/ @elastic/kibana-app-services diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md deleted file mode 100644 index 38fcb7af30b47..0000000000000 --- a/.github/ISSUE_TEMPLATE/Question.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Question -about: Who, what, when, where, and how? - ---- - -Hey, stop right there! - -We use GitHub to track feature requests and bug reports. Please do not submit issues for questions about how to use features of Kibana, how to set Kibana up, best practices, or development related help. - -However, we do want to help! Head on over to our official Kibana forums and ask your questions there. In additional to awesome, knowledgeable community contributors, core Kibana developers are on the forums every single day to help you out. - -The forums are here: https://discuss.elastic.co/c/kibana - -We can't stop you from opening an issue here, but it will likely linger without a response for days or weeks before it is closed and we ask you to join us on the forums instead. Save yourself the time, and ask on the forums today. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..348d756c141b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question + url: https://discuss.elastic.co/c/kibana + about: Please ask and answer questions here. diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 9f0e6e0231feb..4639414b4564e 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -10,15 +10,15 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Fetch Node.js rules http_archive( name = "build_bazel_rules_nodejs", - sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.tar.gz"], + sha256 = "dd7ea7efda7655c218ca707f55c3e1b9c68055a70c31a98f264b3445bc8f4cb1", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.3/rules_nodejs-3.2.3.tar.gz"], ) # Now that we have the rules let's import from them to complete the work load("@build_bazel_rules_nodejs//:index.bzl", "check_rules_nodejs_version", "node_repositories", "yarn_install") # Assure we have at least a given rules_nodejs version -check_rules_nodejs_version(minimum_version_string = "3.2.2") +check_rules_nodejs_version(minimum_version_string = "3.2.3") # Setup the Node.js toolchain for the architectures we want to support # diff --git a/api_docs/charts.json b/api_docs/charts.json index 5c4008d0f25bc..70bf2166de7c8 100644 --- a/api_docs/charts.json +++ b/api_docs/charts.json @@ -1597,7 +1597,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 3160 + "lineNumber": 174 }, "signature": [ { @@ -1816,7 +1816,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 558 + "lineNumber": 56 }, "signature": [ { @@ -1837,7 +1837,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 559 + "lineNumber": 57 } }, { @@ -1848,7 +1848,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 562 + "lineNumber": 60 }, "signature": [ "[number, number[]][]" @@ -1859,7 +1859,7 @@ "label": "[ColorSchemas.Greens]", "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 557 + "lineNumber": 55 } }, { @@ -1875,7 +1875,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1078 + "lineNumber": 74 }, "signature": [ { @@ -1896,7 +1896,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1079 + "lineNumber": 75 } }, { @@ -1907,7 +1907,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1082 + "lineNumber": 78 }, "signature": [ "[number, number[]][]" @@ -1918,7 +1918,7 @@ "label": "[ColorSchemas.Greys]", "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1077 + "lineNumber": 73 } }, { @@ -1934,7 +1934,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1598 + "lineNumber": 92 }, "signature": [ { @@ -1955,7 +1955,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1599 + "lineNumber": 93 } }, { @@ -1966,7 +1966,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1602 + "lineNumber": 96 }, "signature": [ "[number, number[]][]" @@ -1977,7 +1977,7 @@ "label": "[ColorSchemas.Reds]", "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 1597 + "lineNumber": 91 } }, { @@ -1993,7 +1993,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2118 + "lineNumber": 110 }, "signature": [ { @@ -2014,7 +2014,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2119 + "lineNumber": 111 } }, { @@ -2025,7 +2025,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2122 + "lineNumber": 114 }, "signature": [ "[number, number[]][]" @@ -2036,7 +2036,7 @@ "label": "[ColorSchemas.YellowToRed]", "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2117 + "lineNumber": 109 } }, { @@ -2052,7 +2052,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2639 + "lineNumber": 129 }, "signature": [ { @@ -2073,7 +2073,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2640 + "lineNumber": 130 } }, { @@ -2084,7 +2084,7 @@ "description": [], "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2643 + "lineNumber": 133 }, "signature": [ "[number, number[]][]" @@ -2095,7 +2095,7 @@ "label": "[ColorSchemas.GreenToRed]", "source": { "path": "src/plugins/charts/public/static/color_maps/color_maps.ts", - "lineNumber": 2638 + "lineNumber": 128 } } ], diff --git a/api_docs/data.json b/api_docs/data.json index a78aec92b1fa5..a9ef03d881ce8 100644 --- a/api_docs/data.json +++ b/api_docs/data.json @@ -608,7 +608,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 263 + "lineNumber": 265 } }, { @@ -634,7 +634,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 294 + "lineNumber": 296 } }, { @@ -654,7 +654,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 305 + "lineNumber": 307 } }, { @@ -680,7 +680,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 314 + "lineNumber": 316 } }, { @@ -712,7 +712,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 369 + "lineNumber": 371 } }, { @@ -736,7 +736,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 373 + "lineNumber": 375 } }, { @@ -760,7 +760,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 377 + "lineNumber": 379 } }, { @@ -782,7 +782,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 381 + "lineNumber": 383 } } ], @@ -790,7 +790,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 381 + "lineNumber": 383 } }, { @@ -812,7 +812,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } }, { @@ -825,7 +825,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } } ], @@ -833,7 +833,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } }, { @@ -849,7 +849,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 393 + "lineNumber": 395 } }, { @@ -865,7 +865,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 399 + "lineNumber": 401 } }, { @@ -883,7 +883,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 408 + "lineNumber": 410 } }, { @@ -905,7 +905,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 412 + "lineNumber": 414 } } ], @@ -913,7 +913,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 412 + "lineNumber": 414 } }, { @@ -936,7 +936,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 426 + "lineNumber": 428 } }, { @@ -960,7 +960,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 430 + "lineNumber": 432 } }, { @@ -976,7 +976,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 434 + "lineNumber": 436 } }, { @@ -992,7 +992,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 439 + "lineNumber": 441 } }, { @@ -1003,7 +1003,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 446 + "lineNumber": 448 }, "signature": [ { @@ -1023,7 +1023,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 450 + "lineNumber": 452 }, "signature": [ { @@ -1068,7 +1068,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 480 + "lineNumber": 482 } } ], @@ -1076,7 +1076,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 480 + "lineNumber": 482 } } ], @@ -1101,7 +1101,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 58 + "lineNumber": 65 }, "signature": [ { @@ -1121,7 +1121,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 59 + "lineNumber": 66 }, "signature": [ { @@ -1142,7 +1142,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 62 + "lineNumber": 69 }, "signature": [ { @@ -1180,7 +1180,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 65 + "lineNumber": 72 } }, { @@ -1217,7 +1217,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 66 + "lineNumber": 73 } }, { @@ -1236,7 +1236,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 67 + "lineNumber": 74 } } ], @@ -1244,7 +1244,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 64 + "lineNumber": 71 } }, { @@ -1280,7 +1280,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 79 + "lineNumber": 86 } } ], @@ -1288,7 +1288,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 79 + "lineNumber": 86 } }, { @@ -1317,7 +1317,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 97 + "lineNumber": 104 } } ], @@ -1325,7 +1325,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 97 + "lineNumber": 104 } }, { @@ -1366,7 +1366,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 111 + "lineNumber": 118 } }, { @@ -1379,7 +1379,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 112 + "lineNumber": 119 } } ], @@ -1429,7 +1429,7 @@ "label": "createAggConfig", "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 110 + "lineNumber": 117 }, "tags": [], "returnComment": [] @@ -1472,7 +1472,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 153 + "lineNumber": 160 } } ], @@ -1480,7 +1480,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 153 + "lineNumber": 160 } }, { @@ -1502,7 +1502,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 165 + "lineNumber": 172 } } ], @@ -1510,7 +1510,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 165 + "lineNumber": 172 } }, { @@ -1534,7 +1534,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 227 + "lineNumber": 241 } }, { @@ -1563,7 +1563,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 231 + "lineNumber": 245 } } ], @@ -1571,7 +1571,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 231 + "lineNumber": 245 } }, { @@ -1601,7 +1601,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 235 + "lineNumber": 249 } } ], @@ -1609,7 +1609,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 235 + "lineNumber": 249 } }, { @@ -1639,7 +1639,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 239 + "lineNumber": 253 } } ], @@ -1647,7 +1647,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 239 + "lineNumber": 253 } }, { @@ -1677,7 +1677,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 243 + "lineNumber": 257 } } ], @@ -1685,7 +1685,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 243 + "lineNumber": 257 } }, { @@ -1715,7 +1715,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 247 + "lineNumber": 261 } } ], @@ -1723,7 +1723,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 247 + "lineNumber": 261 } }, { @@ -1753,7 +1753,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 251 + "lineNumber": 265 } } ], @@ -1761,7 +1761,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 251 + "lineNumber": 265 } }, { @@ -1785,7 +1785,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 255 + "lineNumber": 269 } }, { @@ -1815,7 +1815,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 269 + "lineNumber": 283 } } ], @@ -1823,7 +1823,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 269 + "lineNumber": 283 } }, { @@ -1851,7 +1851,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 284 + "lineNumber": 298 } }, { @@ -1885,7 +1885,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 298 + "lineNumber": 312 } } ], @@ -1895,7 +1895,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 298 + "lineNumber": 312 } }, { @@ -1941,7 +1941,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } }, { @@ -1961,7 +1961,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } } ], @@ -1969,13 +1969,13 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } } ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 57 + "lineNumber": 64 }, "initialIsOpen": false }, @@ -6059,7 +6059,7 @@ "type": "Function", "label": "setField", "signature": [ - "(field: K, value: ", + "(field: K, value: ", { "pluginId": "data", "scope": "common", @@ -6123,7 +6123,7 @@ "type": "Function", "label": "removeField", "signature": [ - "(field: K) => this" + "(field: K) => this" ], "description": [ "\nremove field" @@ -6250,7 +6250,7 @@ "type": "Function", "label": "getField", "signature": [ - "(field: K, recurse?: boolean) => ", + "(field: K, recurse?: boolean) => ", { "pluginId": "data", "scope": "common", @@ -6303,7 +6303,7 @@ "type": "Function", "label": "getOwnField", "signature": [ - "(field: K) => ", + "(field: K) => ", { "pluginId": "data", "scope": "common", @@ -7635,7 +7635,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 204 + "lineNumber": 207 }, "signature": [ "FunctionDefinition" @@ -7649,7 +7649,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 205 + "lineNumber": 208 }, "signature": [ "FunctionDefinition" @@ -7663,7 +7663,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 206 + "lineNumber": 209 }, "signature": [ "FunctionDefinition" @@ -7677,7 +7677,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 207 + "lineNumber": 210 }, "signature": [ "FunctionDefinition" @@ -7691,7 +7691,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 208 + "lineNumber": 211 }, "signature": [ "FunctionDefinition" @@ -7705,7 +7705,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 209 + "lineNumber": 212 }, "signature": [ "FunctionDefinition" @@ -7719,7 +7719,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 210 + "lineNumber": 213 }, "signature": [ "FunctionDefinition" @@ -7733,7 +7733,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 211 + "lineNumber": 214 }, "signature": [ "FunctionDefinition" @@ -7747,7 +7747,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 212 + "lineNumber": 215 }, "signature": [ "FunctionDefinition" @@ -7761,7 +7761,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 213 + "lineNumber": 216 }, "signature": [ "FunctionDefinition" @@ -7775,7 +7775,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 214 + "lineNumber": 217 }, "signature": [ "FunctionDefinition" @@ -7789,7 +7789,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 215 + "lineNumber": 218 }, "signature": [ "FunctionDefinition" @@ -7803,7 +7803,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 216 + "lineNumber": 219 }, "signature": [ "FunctionDefinition" @@ -7817,7 +7817,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 217 + "lineNumber": 220 }, "signature": [ "FunctionDefinition" @@ -7831,7 +7831,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 218 + "lineNumber": 221 }, "signature": [ "FunctionDefinition" @@ -7845,7 +7845,21 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 219 + "lineNumber": 222 + }, + "signature": [ + "FunctionDefinition" + ] + }, + { + "tags": [], + "id": "def-public.AggFunctionsMapping.aggFilteredMetric", + "type": "Object", + "label": "aggFilteredMetric", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/types.ts", + "lineNumber": 223 }, "signature": [ "FunctionDefinition" @@ -7859,7 +7873,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 220 + "lineNumber": 224 }, "signature": [ "FunctionDefinition" @@ -7873,7 +7887,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 221 + "lineNumber": 225 }, "signature": [ "FunctionDefinition" @@ -7887,7 +7901,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 222 + "lineNumber": 226 }, "signature": [ "FunctionDefinition" @@ -7901,7 +7915,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 223 + "lineNumber": 227 }, "signature": [ "FunctionDefinition" @@ -7915,7 +7929,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 224 + "lineNumber": 228 }, "signature": [ "FunctionDefinition" @@ -7929,7 +7943,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 225 + "lineNumber": 229 }, "signature": [ "FunctionDefinition" @@ -7943,7 +7957,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 226 + "lineNumber": 230 }, "signature": [ "FunctionDefinition" @@ -7957,7 +7971,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 227 + "lineNumber": 231 }, "signature": [ "FunctionDefinition" @@ -7971,7 +7985,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 228 + "lineNumber": 232 }, "signature": [ "FunctionDefinition" @@ -7985,7 +7999,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 229 + "lineNumber": 233 }, "signature": [ "FunctionDefinition" @@ -7999,7 +8013,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 230 + "lineNumber": 234 }, "signature": [ "FunctionDefinition" @@ -8013,7 +8027,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 231 + "lineNumber": 235 }, "signature": [ "FunctionDefinition" @@ -8027,7 +8041,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 232 + "lineNumber": 236 }, "signature": [ "FunctionDefinition" @@ -8041,7 +8055,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 233 + "lineNumber": 237 }, "signature": [ "FunctionDefinition" @@ -8055,7 +8069,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 234 + "lineNumber": 238 }, "signature": [ "FunctionDefinition" @@ -8069,7 +8083,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 235 + "lineNumber": 239 }, "signature": [ "FunctionDefinition" @@ -8078,7 +8092,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 203 + "lineNumber": 206 }, "initialIsOpen": false }, @@ -11407,7 +11421,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 139 + "lineNumber": 141 }, "signature": [ "{ calculateAutoTimeExpression: (range: TimeRange) => string | undefined; getDateMetaByDatatableColumn: (column: DatatableColumn) => Promise<{ timeZone: string; timeRange?: TimeRange | undefined; interval: string; } | undefined>; datatableUtilities: { getIndexPattern: (column: DatatableColumn) => Promise; getAggConfig: (column: DatatableColumn) => Promise; isFilterable: (column: DatatableColumn) => boolean; }; createAggConfigs: (indexPattern: IndexPattern, configStates?: Pick>" @@ -11946,7 +11960,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 29 + "lineNumber": 32 }, "signature": [ "{ type: \"kibana_context\"; } & ExecutionContextSearch" @@ -18807,7 +18821,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 204 + "lineNumber": 207 }, "signature": [ "FunctionDefinition" @@ -18821,7 +18835,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 205 + "lineNumber": 208 }, "signature": [ "FunctionDefinition" @@ -18835,7 +18849,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 206 + "lineNumber": 209 }, "signature": [ "FunctionDefinition" @@ -18849,7 +18863,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 207 + "lineNumber": 210 }, "signature": [ "FunctionDefinition" @@ -18863,7 +18877,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 208 + "lineNumber": 211 }, "signature": [ "FunctionDefinition" @@ -18877,7 +18891,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 209 + "lineNumber": 212 }, "signature": [ "FunctionDefinition" @@ -18891,7 +18905,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 210 + "lineNumber": 213 }, "signature": [ "FunctionDefinition" @@ -18905,7 +18919,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 211 + "lineNumber": 214 }, "signature": [ "FunctionDefinition" @@ -18919,7 +18933,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 212 + "lineNumber": 215 }, "signature": [ "FunctionDefinition" @@ -18933,7 +18947,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 213 + "lineNumber": 216 }, "signature": [ "FunctionDefinition" @@ -18947,7 +18961,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 214 + "lineNumber": 217 }, "signature": [ "FunctionDefinition" @@ -18961,7 +18975,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 215 + "lineNumber": 218 }, "signature": [ "FunctionDefinition" @@ -18975,7 +18989,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 216 + "lineNumber": 219 }, "signature": [ "FunctionDefinition" @@ -18989,7 +19003,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 217 + "lineNumber": 220 }, "signature": [ "FunctionDefinition" @@ -19003,7 +19017,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 218 + "lineNumber": 221 }, "signature": [ "FunctionDefinition" @@ -19017,7 +19031,21 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 219 + "lineNumber": 222 + }, + "signature": [ + "FunctionDefinition" + ] + }, + { + "tags": [], + "id": "def-server.AggFunctionsMapping.aggFilteredMetric", + "type": "Object", + "label": "aggFilteredMetric", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/types.ts", + "lineNumber": 223 }, "signature": [ "FunctionDefinition" @@ -19031,7 +19059,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 220 + "lineNumber": 224 }, "signature": [ "FunctionDefinition" @@ -19045,7 +19073,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 221 + "lineNumber": 225 }, "signature": [ "FunctionDefinition" @@ -19059,7 +19087,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 222 + "lineNumber": 226 }, "signature": [ "FunctionDefinition" @@ -19073,7 +19101,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 223 + "lineNumber": 227 }, "signature": [ "FunctionDefinition" @@ -19087,7 +19115,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 224 + "lineNumber": 228 }, "signature": [ "FunctionDefinition" @@ -19101,7 +19129,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 225 + "lineNumber": 229 }, "signature": [ "FunctionDefinition" @@ -19115,7 +19143,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 226 + "lineNumber": 230 }, "signature": [ "FunctionDefinition" @@ -19129,7 +19157,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 227 + "lineNumber": 231 }, "signature": [ "FunctionDefinition" @@ -19143,7 +19171,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 228 + "lineNumber": 232 }, "signature": [ "FunctionDefinition" @@ -19157,7 +19185,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 229 + "lineNumber": 233 }, "signature": [ "FunctionDefinition" @@ -19171,7 +19199,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 230 + "lineNumber": 234 }, "signature": [ "FunctionDefinition" @@ -19185,7 +19213,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 231 + "lineNumber": 235 }, "signature": [ "FunctionDefinition" @@ -19199,7 +19227,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 232 + "lineNumber": 236 }, "signature": [ "FunctionDefinition" @@ -19213,7 +19241,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 233 + "lineNumber": 237 }, "signature": [ "FunctionDefinition" @@ -19227,7 +19255,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 234 + "lineNumber": 238 }, "signature": [ "FunctionDefinition" @@ -19241,7 +19269,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 235 + "lineNumber": 239 }, "signature": [ "FunctionDefinition" @@ -19250,7 +19278,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 203 + "lineNumber": 206 }, "initialIsOpen": false }, @@ -20453,7 +20481,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 14 + "lineNumber": 15 }, "signature": [ "{ filters?: Filter[] | undefined; query?: Query | Query[] | undefined; timeRange?: TimeRange | undefined; }" @@ -20499,7 +20527,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 25 + "lineNumber": 26 }, "signature": [ "ExpressionFunctionDefinition<\"kibana_context\", ", @@ -20530,7 +20558,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 20 + "lineNumber": 21 }, "signature": [ "{ type: \"kibana_context\"; } & ExecutionContextSearch" @@ -20593,7 +20621,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 58 + "lineNumber": 59 }, "signature": [ "AggType>" @@ -20722,7 +20750,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 29 + "lineNumber": 32 }, "signature": [ "{ type: \"kibana_context\"; } & ExecutionContextSearch" @@ -22654,7 +22682,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 40 + "lineNumber": 42 } }, { @@ -22667,7 +22695,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 41 + "lineNumber": 43 } }, { @@ -22680,7 +22708,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 42 + "lineNumber": 44 } }, { @@ -22693,7 +22721,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 43 + "lineNumber": 45 } }, { @@ -22706,7 +22734,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 44 + "lineNumber": 46 } }, { @@ -22725,7 +22753,7 @@ "description": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 45 + "lineNumber": 47 } } ], @@ -22733,7 +22761,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/es_query/filters/build_filters.ts", - "lineNumber": 39 + "lineNumber": 41 }, "initialIsOpen": false }, @@ -23062,7 +23090,7 @@ "section": "def-common.FilterStateStore", "text": "FilterStateStore" }, - ") => ", + " | undefined) => ", { "pluginId": "data", "scope": "common", @@ -23183,9 +23211,9 @@ } }, { - "type": "Enum", + "type": "CompoundType", "label": "store", - "isRequired": true, + "isRequired": false, "signature": [ { "pluginId": "data", @@ -23193,7 +23221,8 @@ "docId": "kibDataPluginApi", "section": "def-common.FilterStateStore", "text": "FilterStateStore" - } + }, + " | undefined" ], "description": [], "source": { diff --git a/api_docs/data_search.json b/api_docs/data_search.json index 68cf4a1123bdb..a75b669cbd288 100644 --- a/api_docs/data_search.json +++ b/api_docs/data_search.json @@ -2955,7 +2955,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 263 + "lineNumber": 265 } }, { @@ -2981,7 +2981,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 294 + "lineNumber": 296 } }, { @@ -3001,7 +3001,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 305 + "lineNumber": 307 } }, { @@ -3027,7 +3027,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 314 + "lineNumber": 316 } }, { @@ -3059,7 +3059,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 369 + "lineNumber": 371 } }, { @@ -3083,7 +3083,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 373 + "lineNumber": 375 } }, { @@ -3107,7 +3107,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 377 + "lineNumber": 379 } }, { @@ -3129,7 +3129,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 381 + "lineNumber": 383 } } ], @@ -3137,7 +3137,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 381 + "lineNumber": 383 } }, { @@ -3159,7 +3159,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } }, { @@ -3172,7 +3172,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } } ], @@ -3180,7 +3180,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 385 + "lineNumber": 387 } }, { @@ -3196,7 +3196,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 393 + "lineNumber": 395 } }, { @@ -3212,7 +3212,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 399 + "lineNumber": 401 } }, { @@ -3230,7 +3230,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 408 + "lineNumber": 410 } }, { @@ -3252,7 +3252,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 412 + "lineNumber": 414 } } ], @@ -3260,7 +3260,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 412 + "lineNumber": 414 } }, { @@ -3283,7 +3283,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 426 + "lineNumber": 428 } }, { @@ -3307,7 +3307,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 430 + "lineNumber": 432 } }, { @@ -3323,7 +3323,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 434 + "lineNumber": 436 } }, { @@ -3339,7 +3339,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 439 + "lineNumber": 441 } }, { @@ -3350,7 +3350,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 446 + "lineNumber": 448 }, "signature": [ { @@ -3370,7 +3370,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 450 + "lineNumber": 452 }, "signature": [ { @@ -3415,7 +3415,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 480 + "lineNumber": 482 } } ], @@ -3423,7 +3423,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 480 + "lineNumber": 482 } } ], @@ -3448,7 +3448,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 58 + "lineNumber": 65 }, "signature": [ { @@ -3468,7 +3468,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 59 + "lineNumber": 66 }, "signature": [ { @@ -3489,7 +3489,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 62 + "lineNumber": 69 }, "signature": [ { @@ -3527,7 +3527,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 65 + "lineNumber": 72 } }, { @@ -3564,7 +3564,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 66 + "lineNumber": 73 } }, { @@ -3583,7 +3583,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 67 + "lineNumber": 74 } } ], @@ -3591,7 +3591,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 64 + "lineNumber": 71 } }, { @@ -3627,7 +3627,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 79 + "lineNumber": 86 } } ], @@ -3635,7 +3635,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 79 + "lineNumber": 86 } }, { @@ -3664,7 +3664,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 97 + "lineNumber": 104 } } ], @@ -3672,7 +3672,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 97 + "lineNumber": 104 } }, { @@ -3713,7 +3713,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 111 + "lineNumber": 118 } }, { @@ -3726,7 +3726,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 112 + "lineNumber": 119 } } ], @@ -3776,7 +3776,7 @@ "label": "createAggConfig", "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 110 + "lineNumber": 117 }, "tags": [], "returnComment": [] @@ -3819,7 +3819,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 153 + "lineNumber": 160 } } ], @@ -3827,7 +3827,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 153 + "lineNumber": 160 } }, { @@ -3849,7 +3849,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 165 + "lineNumber": 172 } } ], @@ -3857,7 +3857,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 165 + "lineNumber": 172 } }, { @@ -3881,7 +3881,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 227 + "lineNumber": 241 } }, { @@ -3910,7 +3910,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 231 + "lineNumber": 245 } } ], @@ -3918,7 +3918,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 231 + "lineNumber": 245 } }, { @@ -3948,7 +3948,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 235 + "lineNumber": 249 } } ], @@ -3956,7 +3956,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 235 + "lineNumber": 249 } }, { @@ -3986,7 +3986,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 239 + "lineNumber": 253 } } ], @@ -3994,7 +3994,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 239 + "lineNumber": 253 } }, { @@ -4024,7 +4024,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 243 + "lineNumber": 257 } } ], @@ -4032,7 +4032,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 243 + "lineNumber": 257 } }, { @@ -4062,7 +4062,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 247 + "lineNumber": 261 } } ], @@ -4070,7 +4070,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 247 + "lineNumber": 261 } }, { @@ -4100,7 +4100,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 251 + "lineNumber": 265 } } ], @@ -4108,7 +4108,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 251 + "lineNumber": 265 } }, { @@ -4132,7 +4132,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 255 + "lineNumber": 269 } }, { @@ -4162,7 +4162,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 269 + "lineNumber": 283 } } ], @@ -4170,7 +4170,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 269 + "lineNumber": 283 } }, { @@ -4198,7 +4198,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 284 + "lineNumber": 298 } }, { @@ -4232,7 +4232,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 298 + "lineNumber": 312 } } ], @@ -4242,7 +4242,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 298 + "lineNumber": 312 } }, { @@ -4288,7 +4288,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } }, { @@ -4308,7 +4308,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } } ], @@ -4316,13 +4316,13 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 307 + "lineNumber": 321 } } ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 57 + "lineNumber": 64 }, "initialIsOpen": false }, @@ -4572,7 +4572,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 70 + "lineNumber": 71 } }, { @@ -4583,7 +4583,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 72 + "lineNumber": 73 } }, { @@ -4594,7 +4594,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 73 + "lineNumber": 74 }, "signature": [ "string | undefined" @@ -4613,7 +4613,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 80 + "lineNumber": 81 } }, { @@ -4629,7 +4629,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 87 + "lineNumber": 88 } }, { @@ -4645,7 +4645,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 94 + "lineNumber": 95 } }, { @@ -4658,7 +4658,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 99 + "lineNumber": 100 }, "signature": [ "\"string\" | \"number\" | \"boolean\" | \"object\" | \"date\" | \"ip\" | \"_source\" | \"attachment\" | \"geo_point\" | \"geo_shape\" | \"murmur3\" | \"unknown\" | \"conflict\" | \"nested\" | \"histogram\" | \"null\" | undefined" @@ -4676,7 +4676,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 108 + "lineNumber": 109 }, "signature": [ "((aggConfig: TAggConfig) => string) | (() => string)" @@ -4695,7 +4695,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 123 + "lineNumber": 124 }, "signature": [ "any" @@ -4713,7 +4713,22 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 131 + "lineNumber": 132 + } + }, + { + "tags": [ + "type" + ], + "id": "def-common.AggType.hasNoDslParams", + "type": "boolean", + "label": "hasNoDslParams", + "description": [ + "\nFlag that prevents params from this aggregation from being included in the dsl. Sibling and parent aggs are still written.\n" + ], + "source": { + "path": "src/plugins/data/common/search/aggs/agg_type.ts", + "lineNumber": 138 } }, { @@ -4726,7 +4741,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 138 + "lineNumber": 145 }, "signature": [ "((aggConfig: TAggConfig, key: any, params?: any) => any) | undefined" @@ -4745,7 +4760,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 145 + "lineNumber": 152 }, "signature": [ "TParam[]" @@ -4763,7 +4778,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 155 + "lineNumber": 162 }, "signature": [ "((aggConfig: TAggConfig) => TAggConfig[]) | (() => void | TAggConfig[])" @@ -4781,7 +4796,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 166 + "lineNumber": 173 }, "signature": [ "((aggConfig: TAggConfig) => TAggConfig[]) | (() => void | TAggConfig[])" @@ -4797,7 +4812,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 171 + "lineNumber": 178 }, "signature": [ "() => any" @@ -4815,7 +4830,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 184 + "lineNumber": 191 }, "signature": [ "(resp: any, aggConfigs: ", @@ -4857,7 +4872,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 202 + "lineNumber": 209 }, "signature": [ "(agg: TAggConfig) => ", @@ -4879,7 +4894,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 204 + "lineNumber": 211 }, "signature": [ "(agg: TAggConfig, bucket: any) => any" @@ -4893,7 +4908,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 206 + "lineNumber": 213 }, "signature": [ "((bucket: any, key: any, agg: TAggConfig) => any) | undefined" @@ -4913,7 +4928,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 208 + "lineNumber": 215 } } ], @@ -4924,7 +4939,7 @@ "label": "paramByName", "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 208 + "lineNumber": 215 }, "tags": [], "returnComment": [] @@ -4943,7 +4958,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 212 + "lineNumber": 219 } } ], @@ -4954,7 +4969,7 @@ "label": "getValueBucketPath", "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 212 + "lineNumber": 219 }, "tags": [], "returnComment": [] @@ -4997,7 +5012,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 225 + "lineNumber": 232 } } ], @@ -5008,13 +5023,13 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 225 + "lineNumber": 232 } } ], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 60 + "lineNumber": 61 }, "initialIsOpen": false }, @@ -6450,7 +6465,7 @@ "type": "Function", "label": "setField", "signature": [ - "(field: K, value: ", + "(field: K, value: ", { "pluginId": "data", "scope": "common", @@ -6514,7 +6529,7 @@ "type": "Function", "label": "removeField", "signature": [ - "(field: K) => this" + "(field: K) => this" ], "description": [ "\nremove field" @@ -6641,7 +6656,7 @@ "type": "Function", "label": "getField", "signature": [ - "(field: K, recurse?: boolean) => ", + "(field: K, recurse?: boolean) => ", { "pluginId": "data", "scope": "common", @@ -6694,7 +6709,7 @@ "type": "Function", "label": "getOwnField", "signature": [ - "(field: K) => ", + "(field: K) => ", { "pluginId": "data", "scope": "common", @@ -7625,6 +7640,23 @@ "returnComment": [], "initialIsOpen": false }, + { + "id": "def-common.aggFilteredMetric", + "type": "Function", + "children": [], + "signature": [ + "() => FunctionDefinition" + ], + "description": [], + "label": "aggFilteredMetric", + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts", + "lineNumber": 30 + }, + "tags": [], + "returnComment": [], + "initialIsOpen": false + }, { "id": "def-common.aggFilters", "type": "Function", @@ -8457,6 +8489,76 @@ }, "initialIsOpen": false }, + { + "id": "def-common.filtersToAst", + "type": "Function", + "children": [ + { + "type": "CompoundType", + "label": "filters", + "isRequired": true, + "signature": [ + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + " | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + "[]" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/filters_to_ast.ts", + "lineNumber": 13 + } + } + ], + "signature": [ + "(filters: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + " | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + "[]) => ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionAstExpressionBuilder", + "text": "ExpressionAstExpressionBuilder" + }, + "[]" + ], + "description": [], + "label": "filtersToAst", + "source": { + "path": "src/plugins/data/common/search/expressions/filters_to_ast.ts", + "lineNumber": 13 + }, + "tags": [], + "returnComment": [], + "initialIsOpen": false + }, { "id": "def-common.functionWrapper", "type": "Function", @@ -8983,9 +9085,37 @@ { "id": "def-common.getFilterBucketAgg", "type": "Function", - "children": [], + "children": [ + { + "id": "def-common.getFilterBucketAgg.{-getConfig }", + "type": "Object", + "label": "{ getConfig }", + "tags": [], + "description": [], + "children": [ + { + "tags": [], + "id": "def-common.getFilterBucketAgg.{-getConfig }.getConfig", + "type": "Function", + "label": "getConfig", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/buckets/filter.ts", + "lineNumber": 27 + }, + "signature": [ + "(key: string) => any" + ] + } + ], + "source": { + "path": "src/plugins/data/common/search/aggs/buckets/filter.ts", + "lineNumber": 27 + } + } + ], "signature": [ - "() => ", + "({ getConfig }: { getConfig: (key: string) => any; }) => ", { "pluginId": "data", "scope": "common", @@ -9007,7 +9137,40 @@ "label": "getFilterBucketAgg", "source": { "path": "src/plugins/data/common/search/aggs/buckets/filter.ts", - "lineNumber": 24 + "lineNumber": 27 + }, + "tags": [], + "returnComment": [], + "initialIsOpen": false + }, + { + "id": "def-common.getFilteredMetricAgg", + "type": "Function", + "children": [], + "signature": [ + "() => ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.MetricAggType", + "text": "MetricAggType" + }, + "<", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.IMetricAggConfig", + "text": "IMetricAggConfig" + }, + ">" + ], + "description": [], + "label": "getFilteredMetricAgg", + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric.ts", + "lineNumber": 30 }, "tags": [], "returnComment": [], @@ -11475,7 +11638,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 38 + "lineNumber": 45 }, "signature": [ { @@ -11490,7 +11653,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/agg_configs.ts", - "lineNumber": 37 + "lineNumber": 44 }, "initialIsOpen": false }, @@ -11511,7 +11674,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 204 + "lineNumber": 207 }, "signature": [ "FunctionDefinition" @@ -11525,7 +11688,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 205 + "lineNumber": 208 }, "signature": [ "FunctionDefinition" @@ -11539,7 +11702,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 206 + "lineNumber": 209 }, "signature": [ "FunctionDefinition" @@ -11553,7 +11716,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 207 + "lineNumber": 210 }, "signature": [ "FunctionDefinition" @@ -11567,7 +11730,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 208 + "lineNumber": 211 }, "signature": [ "FunctionDefinition" @@ -11581,7 +11744,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 209 + "lineNumber": 212 }, "signature": [ "FunctionDefinition" @@ -11595,7 +11758,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 210 + "lineNumber": 213 }, "signature": [ "FunctionDefinition" @@ -11609,7 +11772,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 211 + "lineNumber": 214 }, "signature": [ "FunctionDefinition" @@ -11623,7 +11786,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 212 + "lineNumber": 215 }, "signature": [ "FunctionDefinition" @@ -11637,7 +11800,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 213 + "lineNumber": 216 }, "signature": [ "FunctionDefinition" @@ -11651,7 +11814,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 214 + "lineNumber": 217 }, "signature": [ "FunctionDefinition" @@ -11665,7 +11828,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 215 + "lineNumber": 218 }, "signature": [ "FunctionDefinition" @@ -11679,7 +11842,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 216 + "lineNumber": 219 }, "signature": [ "FunctionDefinition" @@ -11693,7 +11856,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 217 + "lineNumber": 220 }, "signature": [ "FunctionDefinition" @@ -11707,7 +11870,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 218 + "lineNumber": 221 }, "signature": [ "FunctionDefinition" @@ -11721,7 +11884,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 219 + "lineNumber": 222 }, "signature": [ "FunctionDefinition" @@ -11729,13 +11892,13 @@ }, { "tags": [], - "id": "def-common.AggFunctionsMapping.aggCardinality", + "id": "def-common.AggFunctionsMapping.aggFilteredMetric", "type": "Object", - "label": "aggCardinality", + "label": "aggFilteredMetric", "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 220 + "lineNumber": 223 }, "signature": [ "FunctionDefinition" @@ -11743,13 +11906,27 @@ }, { "tags": [], - "id": "def-common.AggFunctionsMapping.aggCount", + "id": "def-common.AggFunctionsMapping.aggCardinality", "type": "Object", - "label": "aggCount", + "label": "aggCardinality", "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 221 + "lineNumber": 224 + }, + "signature": [ + "FunctionDefinition" + ] + }, + { + "tags": [], + "id": "def-common.AggFunctionsMapping.aggCount", + "type": "Object", + "label": "aggCount", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/types.ts", + "lineNumber": 225 }, "signature": [ "FunctionDefinition" @@ -11763,7 +11940,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 222 + "lineNumber": 226 }, "signature": [ "FunctionDefinition" @@ -11777,7 +11954,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 223 + "lineNumber": 227 }, "signature": [ "FunctionDefinition" @@ -11791,7 +11968,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 224 + "lineNumber": 228 }, "signature": [ "FunctionDefinition" @@ -11805,7 +11982,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 225 + "lineNumber": 229 }, "signature": [ "FunctionDefinition" @@ -11819,7 +11996,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 226 + "lineNumber": 230 }, "signature": [ "FunctionDefinition" @@ -11833,7 +12010,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 227 + "lineNumber": 231 }, "signature": [ "FunctionDefinition" @@ -11847,7 +12024,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 228 + "lineNumber": 232 }, "signature": [ "FunctionDefinition" @@ -11861,7 +12038,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 229 + "lineNumber": 233 }, "signature": [ "FunctionDefinition" @@ -11875,7 +12052,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 230 + "lineNumber": 234 }, "signature": [ "FunctionDefinition" @@ -11889,7 +12066,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 231 + "lineNumber": 235 }, "signature": [ "FunctionDefinition" @@ -11903,7 +12080,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 232 + "lineNumber": 236 }, "signature": [ "FunctionDefinition" @@ -11917,7 +12094,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 233 + "lineNumber": 237 }, "signature": [ "FunctionDefinition" @@ -11931,7 +12108,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 234 + "lineNumber": 238 }, "signature": [ "FunctionDefinition" @@ -11945,7 +12122,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 235 + "lineNumber": 239 }, "signature": [ "FunctionDefinition" @@ -11954,7 +12131,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 203 + "lineNumber": 206 }, "initialIsOpen": false }, @@ -12823,7 +13000,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/buckets/filter.ts", - "lineNumber": 21 + "lineNumber": 24 }, "signature": [ "Partial<{ top_left: GeoPoint; top_right: GeoPoint; bottom_right: GeoPoint; bottom_left: GeoPoint; }> | { wkt: string; } | GeoBox | undefined" @@ -12832,7 +13009,76 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/buckets/filter.ts", - "lineNumber": 20 + "lineNumber": 23 + }, + "initialIsOpen": false + }, + { + "id": "def-common.AggParamsFilteredMetric", + "type": "Interface", + "label": "AggParamsFilteredMetric", + "signature": [ + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.AggParamsFilteredMetric", + "text": "AggParamsFilteredMetric" + }, + " extends ", + "BaseAggParams" + ], + "description": [], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.AggParamsFilteredMetric.customMetric", + "type": "Object", + "label": "customMetric", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric.ts", + "lineNumber": 18 + }, + "signature": [ + "{ type: string; enabled?: boolean | undefined; id?: string | undefined; params?: {} | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.SerializableState", + "text": "SerializableState" + }, + " | undefined; schema?: string | undefined; } | undefined" + ] + }, + { + "tags": [], + "id": "def-common.AggParamsFilteredMetric.customBucket", + "type": "Object", + "label": "customBucket", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric.ts", + "lineNumber": 19 + }, + "signature": [ + "{ type: string; enabled?: boolean | undefined; id?: string | undefined; params?: {} | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.SerializableState", + "text": "SerializableState" + }, + " | undefined; schema?: string | undefined; } | undefined" + ] + } + ], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric.ts", + "lineNumber": 17 }, "initialIsOpen": false }, @@ -14347,6 +14593,20 @@ "boolean | undefined" ] }, + { + "tags": [], + "id": "def-common.AggTypeConfig.hasNoDslParams", + "type": "CompoundType", + "label": "hasNoDslParams", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/agg_type.ts", + "lineNumber": 35 + }, + "signature": [ + "boolean | undefined" + ] + }, { "tags": [], "id": "def-common.AggTypeConfig.params", @@ -14355,7 +14615,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 35 + "lineNumber": 36 }, "signature": [ "Partial[] | undefined" @@ -14369,7 +14629,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 36 + "lineNumber": 37 }, "signature": [ "\"string\" | \"number\" | \"boolean\" | \"object\" | \"date\" | \"ip\" | \"_source\" | \"attachment\" | \"geo_point\" | \"geo_shape\" | \"murmur3\" | \"unknown\" | \"conflict\" | \"nested\" | \"histogram\" | \"null\" | undefined" @@ -14383,7 +14643,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 37 + "lineNumber": 38 }, "signature": [ "((aggConfig: TAggConfig) => TAggConfig[]) | (() => void | TAggConfig[]) | undefined" @@ -14397,7 +14657,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 38 + "lineNumber": 39 }, "signature": [ "((aggConfig: TAggConfig) => TAggConfig[]) | (() => void | TAggConfig[]) | undefined" @@ -14411,7 +14671,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 39 + "lineNumber": 40 }, "signature": [ "boolean | undefined" @@ -14425,7 +14685,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 40 + "lineNumber": 41 }, "signature": [ "boolean | undefined" @@ -14439,7 +14699,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 41 + "lineNumber": 42 }, "signature": [ "(() => any) | undefined" @@ -14453,7 +14713,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 42 + "lineNumber": 43 }, "signature": [ "((resp: any, aggConfigs: ", @@ -14491,7 +14751,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 51 + "lineNumber": 52 }, "signature": [ "((agg: TAggConfig) => ", @@ -14513,7 +14773,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 52 + "lineNumber": 53 }, "signature": [ "((agg: TAggConfig, bucket: any) => any) | undefined" @@ -14527,7 +14787,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 53 + "lineNumber": 54 }, "signature": [ "((bucket: any, key: any, agg: TAggConfig) => any) | undefined" @@ -14541,7 +14801,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 54 + "lineNumber": 55 }, "signature": [ "((agg: TAggConfig) => string) | undefined" @@ -17926,6 +18186,21 @@ ], "initialIsOpen": false }, + { + "tags": [], + "id": "def-common.aggFilteredMetricFnName", + "type": "string", + "label": "aggFilteredMetricFnName", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts", + "lineNumber": 14 + }, + "signature": [ + "\"aggFilteredMetric\"" + ], + "initialIsOpen": false + }, { "tags": [], "id": "def-common.aggFilterFnName", @@ -18223,7 +18498,7 @@ ], "source": { "path": "src/plugins/data/common/search/aggs/types.ts", - "lineNumber": 139 + "lineNumber": 141 }, "signature": [ "{ calculateAutoTimeExpression: (range: TimeRange) => string | undefined; getDateMetaByDatatableColumn: (column: DatatableColumn) => Promise<{ timeZone: string; timeRange?: TimeRange | undefined; interval: string; } | undefined>; datatableUtilities: { getIndexPattern: (column: DatatableColumn) => Promise; getAggConfig: (column: DatatableColumn) => Promise; isFilterable: (column: DatatableColumn) => boolean; }; createAggConfigs: (indexPattern: IndexPattern, configStates?: Pick | null, object, ", - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExpressionValueBoxed", - "text": "ExpressionValueBoxed" - }, - "<\"kibana_context\", ExecutionContextSearch>, ExecutionContext>" - ], - "initialIsOpen": false - }, - { - "id": "def-common.ExpressionFunctionKibanaContext", + "id": "def-common.ExpressionFunctionExistsFilter", "type": "Type", - "label": "ExpressionFunctionKibanaContext", + "label": "ExpressionFunctionExistsFilter", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 25 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 20 }, "signature": [ - "ExpressionFunctionDefinition<\"kibana_context\", ", + "ExpressionFunctionDefinition<\"existsFilter\", null, Arguments, ", { "pluginId": "expressions", "scope": "common", @@ -18517,30 +18761,15 @@ "section": "def-common.ExpressionValueBoxed", "text": "ExpressionValueBoxed" }, - "<\"kibana_context\", ExecutionContextSearch> | null, Arguments, Promise<", + "<\"kibana_filter\", ", { - "pluginId": "expressions", + "pluginId": "data", "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExpressionValueBoxed", - "text": "ExpressionValueBoxed" + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" }, - "<\"kibana_context\", ExecutionContextSearch>>, ExecutionContext>" - ], - "initialIsOpen": false - }, - { - "id": "def-common.ExpressionFunctionKibanaTimerange", - "type": "Type", - "label": "ExpressionFunctionKibanaTimerange", - "tags": [], - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 15 - }, - "signature": [ - "ExpressionFunctionDefinition<\"timerange\", null, TimeRange, ExpressionValueBoxed<\"timerange\", TimeRange>, ", + ">, ", { "pluginId": "expressions", "scope": "common", @@ -18557,23 +18786,22 @@ "text": "Adapters" }, ", ", - "SerializableState", - ">>" + "SerializableState" ], "initialIsOpen": false }, { - "id": "def-common.ExpressionFunctionKql", + "id": "def-common.ExpressionFunctionField", "type": "Type", - "label": "ExpressionFunctionKql", + "label": "ExpressionFunctionField", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 17 + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 19 }, "signature": [ - "ExpressionFunctionDefinition<\"kql\", null, Arguments, ", + "ExpressionFunctionDefinition<\"field\", null, Arguments, ", { "pluginId": "expressions", "scope": "common", @@ -18581,13 +18809,13 @@ "section": "def-common.ExpressionValueBoxed", "text": "ExpressionValueBoxed" }, - "<\"kibana_query\", ", + "<\"kibana_field\", ", { "pluginId": "data", - "scope": "common", + "scope": "public", "docId": "kibDataPluginApi", - "section": "def-common.Query", - "text": "Query" + "section": "def-public.IndexPatternField", + "text": "IndexPatternField" }, ">, ", { @@ -18611,17 +18839,17 @@ "initialIsOpen": false }, { - "id": "def-common.ExpressionFunctionLucene", + "id": "def-common.ExpressionFunctionKibana", "type": "Type", - "label": "ExpressionFunctionLucene", + "label": "ExpressionFunctionKibana", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", + "path": "src/plugins/data/common/search/expressions/kibana.ts", "lineNumber": 17 }, "signature": [ - "ExpressionFunctionDefinition<\"lucene\", null, Arguments, ", + "ExpressionFunctionDefinition<\"kibana\", ", { "pluginId": "expressions", "scope": "common", @@ -18629,193 +18857,513 @@ "section": "def-common.ExpressionValueBoxed", "text": "ExpressionValueBoxed" }, - "<\"kibana_query\", ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.Query", - "text": "Query" - }, - ">, ", + "<\"kibana_context\", ExecutionContextSearch> | null, object, ", { "pluginId": "expressions", "scope": "common", "docId": "kibExpressionsPluginApi", - "section": "def-common.ExecutionContext", - "text": "ExecutionContext" + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" }, - "<", - { - "pluginId": "inspector", - "scope": "common", - "docId": "kibInspectorPluginApi", - "section": "def-common.Adapters", - "text": "Adapters" - }, - ", ", - "SerializableState" + "<\"kibana_context\", ExecutionContextSearch>, ExecutionContext>" ], "initialIsOpen": false }, { - "id": "def-common.ExpressionValueSearchContext", + "id": "def-common.ExpressionFunctionKibanaContext", "type": "Type", - "label": "ExpressionValueSearchContext", + "label": "ExpressionFunctionKibanaContext", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 20 + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 26 }, "signature": [ - "{ type: \"kibana_context\"; } & ExecutionContextSearch" + "ExpressionFunctionDefinition<\"kibana_context\", ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_context\", ExecutionContextSearch> | null, Arguments, Promise<", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_context\", ExecutionContextSearch>>, ExecutionContext>" ], "initialIsOpen": false }, { - "id": "def-common.FieldTypes", + "id": "def-common.ExpressionFunctionKibanaFilter", "type": "Type", - "label": "FieldTypes", + "label": "ExpressionFunctionKibanaFilter", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/param_types/field.ts", - "lineNumber": 19 + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 18 }, "signature": [ + "ExpressionFunctionDefinition<\"kibanaFilter\", null, Arguments, ", { - "pluginId": "data", + "pluginId": "expressions", "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.KBN_FIELD_TYPES", - "text": "KBN_FIELD_TYPES" + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" }, - "._SOURCE | ", + "<\"kibana_filter\", ", { "pluginId": "data", - "scope": "common", + "scope": "public", "docId": "kibDataPluginApi", - "section": "def-common.KBN_FIELD_TYPES", - "text": "KBN_FIELD_TYPES" + "section": "def-public.Filter", + "text": "Filter" }, - ".ATTACHMENT | ", + ">, ", { - "pluginId": "data", + "pluginId": "expressions", "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.KBN_FIELD_TYPES", - "text": "KBN_FIELD_TYPES" + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" }, - ".BOOLEAN | ", + "<", { - "pluginId": "data", + "pluginId": "inspector", "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.KBN_FIELD_TYPES", - "text": "KBN_FIELD_TYPES" + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" }, - ".DATE | ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.KBN_FIELD_TYPES", - "text": "KBN_FIELD_TYPES" - } + ", ", + "SerializableState" ], "initialIsOpen": false }, { - "id": "def-common.IAggConfig", + "id": "def-common.ExpressionFunctionKibanaTimerange", "type": "Type", - "label": "IAggConfig", - "tags": [ - "name", - "description" - ], + "label": "ExpressionFunctionKibanaTimerange", + "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/agg_config.ts", - "lineNumber": 53 + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 15 }, "signature": [ - "AggConfig" + "ExpressionFunctionDefinition<\"timerange\", null, TimeRange, ExpressionValueBoxed<\"timerange\", TimeRange>, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState", + ">>" ], "initialIsOpen": false }, { - "id": "def-common.IAggType", + "id": "def-common.ExpressionFunctionKql", "type": "Type", - "label": "IAggType", + "label": "ExpressionFunctionKql", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/agg_type.ts", - "lineNumber": 58 + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 17 }, "signature": [ - "AggType>" + "ExpressionFunctionDefinition<\"kql\", null, Arguments, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_query\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Query", + "text": "Query" + }, + ">, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState" ], "initialIsOpen": false }, { - "id": "def-common.IEsSearchResponse", + "id": "def-common.ExpressionFunctionLucene", "type": "Type", - "label": "IEsSearchResponse", + "label": "ExpressionFunctionLucene", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/es_search/types.ts", - "lineNumber": 23 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 17 }, "signature": [ - "IKibanaSearchResponse>" + "ExpressionFunctionDefinition<\"lucene\", null, Arguments, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_query\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Query", + "text": "Query" + }, + ">, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState" ], "initialIsOpen": false }, { - "id": "def-common.IFieldParamType", + "id": "def-common.ExpressionFunctionPhraseFilter", "type": "Type", - "label": "IFieldParamType", + "label": "ExpressionFunctionPhraseFilter", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/param_types/field.ts", + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", "lineNumber": 21 }, "signature": [ - "FieldParamType" + "ExpressionFunctionDefinition<\"rangeFilter\", null, Arguments, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_filter\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + ">, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState" ], "initialIsOpen": false }, { - "id": "def-common.IMetricAggType", + "id": "def-common.ExpressionFunctionRange", "type": "Type", - "label": "IMetricAggType", - "tags": [], - "description": [], - "source": { - "path": "src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts", - "lineNumber": 35 - }, - "signature": [ - "MetricAggType" - ], - "initialIsOpen": false - }, - { + "label": "ExpressionFunctionRange", "tags": [], - "id": "def-common.intervalOptions", - "type": "Array", - "label": "intervalOptions", "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/buckets/_interval_options.ts", - "lineNumber": 15 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 21 }, "signature": [ - "({ display: string; val: string; enabled(agg: ", + "ExpressionFunctionDefinition<\"range\", null, Arguments, ExpressionValueBoxed<\"kibana_range\", Arguments>, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState", + ">>" + ], + "initialIsOpen": false + }, + { + "id": "def-common.ExpressionFunctionRangeFilter", + "type": "Type", + "label": "ExpressionFunctionRangeFilter", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 22 + }, + "signature": [ + "ExpressionFunctionDefinition<\"rangeFilter\", null, Arguments, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_filter\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + }, + ">, ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState" + ], + "initialIsOpen": false + }, + { + "id": "def-common.ExpressionValueSearchContext", + "type": "Type", + "label": "ExpressionValueSearchContext", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 21 + }, + "signature": [ + "{ type: \"kibana_context\"; } & ExecutionContextSearch" + ], + "initialIsOpen": false + }, + { + "id": "def-common.FieldTypes", + "type": "Type", + "label": "FieldTypes", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/param_types/field.ts", + "lineNumber": 19 + }, + "signature": [ + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.KBN_FIELD_TYPES", + "text": "KBN_FIELD_TYPES" + }, + "._SOURCE | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.KBN_FIELD_TYPES", + "text": "KBN_FIELD_TYPES" + }, + ".ATTACHMENT | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.KBN_FIELD_TYPES", + "text": "KBN_FIELD_TYPES" + }, + ".BOOLEAN | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.KBN_FIELD_TYPES", + "text": "KBN_FIELD_TYPES" + }, + ".DATE | ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.KBN_FIELD_TYPES", + "text": "KBN_FIELD_TYPES" + } + ], + "initialIsOpen": false + }, + { + "id": "def-common.IAggConfig", + "type": "Type", + "label": "IAggConfig", + "tags": [ + "name", + "description" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/agg_config.ts", + "lineNumber": 53 + }, + "signature": [ + "AggConfig" + ], + "initialIsOpen": false + }, + { + "id": "def-common.IAggType", + "type": "Type", + "label": "IAggType", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/agg_type.ts", + "lineNumber": 59 + }, + "signature": [ + "AggType>" + ], + "initialIsOpen": false + }, + { + "id": "def-common.IEsSearchResponse", + "type": "Type", + "label": "IEsSearchResponse", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/es_search/types.ts", + "lineNumber": 23 + }, + "signature": [ + "IKibanaSearchResponse>" + ], + "initialIsOpen": false + }, + { + "id": "def-common.IFieldParamType", + "type": "Type", + "label": "IFieldParamType", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/param_types/field.ts", + "lineNumber": 21 + }, + "signature": [ + "FieldParamType" + ], + "initialIsOpen": false + }, + { + "id": "def-common.IMetricAggType", + "type": "Type", + "label": "IMetricAggType", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts", + "lineNumber": 35 + }, + "signature": [ + "MetricAggType" + ], + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-common.intervalOptions", + "type": "Array", + "label": "intervalOptions", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/buckets/_interval_options.ts", + "lineNumber": 15 + }, + "signature": [ + "({ display: string; val: string; enabled(agg: ", { "pluginId": "data", "scope": "common", @@ -19037,7 +19585,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 28 + "lineNumber": 31 }, "signature": [ "\"kibana_context\"" @@ -19052,7 +19600,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 29 + "lineNumber": 32 }, "signature": [ "{ type: \"kibana_context\"; } & ExecutionContextSearch" @@ -19060,65 +19608,110 @@ "initialIsOpen": false }, { - "id": "def-common.KibanaQueryOutput", + "id": "def-common.KibanaField", "type": "Type", - "label": "KibanaQueryOutput", + "label": "KibanaField", "tags": [], "description": [], "source": { "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 25 + "lineNumber": 28 }, "signature": [ - "{ type: \"kibana_query\"; } & Query" + "{ type: \"kibana_field\"; } & IndexPatternField" ], "initialIsOpen": false }, { - "id": "def-common.KibanaTimerangeOutput", + "id": "def-common.KibanaFilter", "type": "Type", - "label": "KibanaTimerangeOutput", + "label": "KibanaFilter", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 13 + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 27 }, "signature": [ - "{ type: \"timerange\"; } & TimeRange" + "{ type: \"kibana_filter\"; } & Filter" ], "initialIsOpen": false }, { + "id": "def-common.KibanaQueryOutput", + "type": "Type", + "label": "KibanaQueryOutput", "tags": [], - "id": "def-common.parentPipelineType", - "type": "string", - "label": "parentPipelineType", "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", "lineNumber": 26 }, + "signature": [ + "{ type: \"kibana_query\"; } & Query" + ], "initialIsOpen": false }, { - "id": "def-common.ParsedInterval", + "id": "def-common.KibanaRange", "type": "Type", - "label": "ParsedInterval", + "label": "KibanaRange", "tags": [], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/utils/date_interval_utils/parse_es_interval.ts", - "lineNumber": 18 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 19 }, "signature": [ - "{ value: number; unit: Unit; type: \"calendar\" | \"fixed\"; }" + "{ type: \"kibana_range\"; } & Arguments" ], "initialIsOpen": false }, { + "id": "def-common.KibanaTimerangeOutput", + "type": "Type", + "label": "KibanaTimerangeOutput", "tags": [], - "id": "def-common.SEARCH_SESSION_TYPE", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 13 + }, + "signature": [ + "{ type: \"timerange\"; } & TimeRange" + ], + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-common.parentPipelineType", + "type": "string", + "label": "parentPipelineType", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 27 + }, + "initialIsOpen": false + }, + { + "id": "def-common.ParsedInterval", + "type": "Type", + "label": "ParsedInterval", + "tags": [], + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/utils/date_interval_utils/parse_es_interval.ts", + "lineNumber": 18 + }, + "signature": [ + "{ value: number; unit: Unit; type: \"calendar\" | \"fixed\"; }" + ], + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-common.SEARCH_SESSION_TYPE", "type": "string", "label": "SEARCH_SESSION_TYPE", "description": [], @@ -19169,7 +19762,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 33 + "lineNumber": 34 }, "initialIsOpen": false }, @@ -19253,634 +19846,2109 @@ "initialIsOpen": false }, { - "id": "def-common.kibana", + "id": "def-common.existsFilterFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibana.name", + "id": "def-common.existsFilterFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 27 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 28 }, "signature": [ - "\"kibana\"" + "\"existsFilter\"" ] }, { "tags": [], - "id": "def-common.kibana.type", + "id": "def-common.existsFilterFunction.type", "type": "string", "label": "type", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 28 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 29 }, "signature": [ - "\"kibana_context\"" + "\"kibana_filter\"" ] }, { "tags": [], - "id": "def-common.kibana.inputTypes", + "id": "def-common.existsFilterFunction.inputTypes", "type": "Array", "label": "inputTypes", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", "lineNumber": 30 }, "signature": [ - "(\"kibana_context\" | \"null\")[]" + "\"null\"[]" ] }, { "tags": [], - "id": "def-common.kibana.help", + "id": "def-common.existsFilterFunction.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 32 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 31 } }, { - "id": "def-common.kibana.args", + "id": "def-common.existsFilterFunction.args", "type": "Object", "tags": [], - "children": [], + "children": [ + { + "id": "def-common.existsFilterFunction.args.field", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.existsFilterFunction.args.field.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 36 + }, + "signature": [ + "\"kibana_field\"[]" + ] + }, + { + "tags": [], + "id": "def-common.existsFilterFunction.args.field.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 37 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.existsFilterFunction.args.field.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 38 + } + } + ], + "description": [], + "label": "field", + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 35 + } + }, + { + "id": "def-common.existsFilterFunction.args.negate", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.existsFilterFunction.args.negate.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 43 + }, + "signature": [ + "\"boolean\"[]" + ] + }, + { + "tags": [], + "id": "def-common.existsFilterFunction.args.negate.default", + "type": "boolean", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 44 + }, + "signature": [ + "false" + ] + }, + { + "tags": [], + "id": "def-common.existsFilterFunction.args.negate.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 45 + } + } + ], + "description": [], + "label": "negate", + "source": { + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 42 + } + } + ], "description": [], "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 36 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 34 } }, { - "id": "def-common.kibana.fn", + "id": "def-common.existsFilterFunction.fn", "type": "Function", "label": "fn", "signature": [ - "(input: Input, _: object, { getSearchContext }: ", - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExecutionContext", - "text": "ExecutionContext" - }, - "<", - { - "pluginId": "inspector", - "scope": "common", - "docId": "kibInspectorPluginApi", - "section": "def-common.Adapters", - "text": "Adapters" - }, - ", ", + "(input: null, args: Arguments) => { $state?: ", { "pluginId": "data", "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.ExecutionContextSearch", - "text": "ExecutionContextSearch" - }, - ">) => ", - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExpressionValueBoxed", - "text": "ExpressionValueBoxed" + "docId": "kibDataPluginApi", + "section": "def-common.FilterState", + "text": "FilterState" }, - "<\"kibana_context\", ", + " | undefined; meta: ", { "pluginId": "data", "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.ExecutionContextSearch", - "text": "ExecutionContextSearch" - } + "docId": "kibDataPluginApi", + "section": "def-common.FilterMeta", + "text": "FilterMeta" + }, + "; query?: any; type: \"kibana_filter\"; }" ], "description": [], "children": [ - { - "type": "CompoundType", - "label": "input", - "isRequired": false, - "signature": [ - "Input" - ], - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 38 - } - }, { "type": "Uncategorized", - "label": "_", + "label": "input", "isRequired": true, "signature": [ - "object" + "null" ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 38 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 51 } }, { "type": "Object", - "label": "{ getSearchContext }", + "label": "args", "isRequired": true, "signature": [ - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExecutionContext", - "text": "ExecutionContext" - }, - "<", - { - "pluginId": "inspector", - "scope": "common", - "docId": "kibInspectorPluginApi", - "section": "def-common.Adapters", - "text": "Adapters" - }, - ", ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.ExecutionContextSearch", - "text": "ExecutionContextSearch" - }, - ">" + "Arguments" ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 38 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 51 } } ], "tags": [], "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 38 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 51 } } ], "description": [], - "label": "kibana", + "label": "existsFilterFunction", "source": { - "path": "src/plugins/data/common/search/expressions/kibana.ts", - "lineNumber": 26 + "path": "src/plugins/data/common/search/expressions/exists_filter.ts", + "lineNumber": 27 }, "initialIsOpen": false }, { - "id": "def-common.kibanaContext", + "id": "def-common.fieldFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaContext.name", + "id": "def-common.fieldFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 32 - } + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 27 + }, + "signature": [ + "\"field\"" + ] }, { - "id": "def-common.kibanaContext.from", - "type": "Object", "tags": [], - "children": [ - { + "id": "def-common.fieldFunction.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 28 + }, + "signature": [ + "\"kibana_field\"" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 29 + }, + "signature": [ + "\"null\"[]" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 30 + } + }, + { + "id": "def-common.fieldFunction.args", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.fieldFunction.args.name", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.fieldFunction.args.name.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 35 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.args.name.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 36 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.args.name.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 37 + } + } + ], + "description": [], + "label": "name", + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 34 + } + }, + { + "id": "def-common.fieldFunction.args.type", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.fieldFunction.args.type.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 42 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.args.type.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 43 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.args.type.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 44 + } + } + ], + "description": [], + "label": "type", + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 41 + } + }, + { + "id": "def-common.fieldFunction.args.script", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.fieldFunction.args.script.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 49 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.fieldFunction.args.script.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 50 + } + } + ], + "description": [], + "label": "script", + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 48 + } + } + ], + "description": [], + "label": "args", + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 33 + } + }, + { + "id": "def-common.fieldFunction.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: null, args: Arguments) => ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_field\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataIndexPatternsPluginApi", + "section": "def-common.IndexPatternField", + "text": "IndexPatternField" + }, + ">" + ], + "description": [], + "children": [ + { + "type": "Uncategorized", + "label": "input", + "isRequired": true, + "signature": [ + "null" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 56 + } + }, + { + "type": "Object", + "label": "args", + "isRequired": true, + "signature": [ + "Arguments" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 56 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 56 + } + } + ], + "description": [], + "label": "fieldFunction", + "source": { + "path": "src/plugins/data/common/search/expressions/field.ts", + "lineNumber": 26 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kibana", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibana.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 27 + }, + "signature": [ + "\"kibana\"" + ] + }, + { + "tags": [], + "id": "def-common.kibana.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 28 + }, + "signature": [ + "\"kibana_context\"" + ] + }, + { + "tags": [], + "id": "def-common.kibana.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 30 + }, + "signature": [ + "(\"kibana_context\" | \"null\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibana.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 32 + } + }, + { + "id": "def-common.kibana.args", + "type": "Object", + "tags": [], + "children": [], + "description": [], + "label": "args", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 36 + } + }, + { + "id": "def-common.kibana.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: Input, _: object, { getSearchContext }: ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ExecutionContextSearch", + "text": "ExecutionContextSearch" + }, + ">) => ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "<\"kibana_context\", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ExecutionContextSearch", + "text": "ExecutionContextSearch" + } + ], + "description": [], + "children": [ + { + "type": "CompoundType", + "label": "input", + "isRequired": false, + "signature": [ + "Input" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 38 + } + }, + { + "type": "Uncategorized", + "label": "_", + "isRequired": true, + "signature": [ + "object" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 38 + } + }, + { + "type": "Object", + "label": "{ getSearchContext }", + "isRequired": true, + "signature": [ + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ExecutionContextSearch", + "text": "ExecutionContextSearch" + }, + ">" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 38 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 38 + } + } + ], + "description": [], + "label": "kibana", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana.ts", + "lineNumber": 26 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kibanaContext", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContext.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 35 + } + }, + { + "id": "def-common.kibanaContext.from", + "type": "Object", + "tags": [], + "children": [ + { "id": "def-common.kibanaContext.from.null", "type": "Function", "children": [], "signature": [ - "() => { type: string; }" + "() => { type: string; }" + ], + "description": [], + "label": "null", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 37 + }, + "tags": [], + "returnComment": [] + } + ], + "description": [], + "label": "from", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 36 + } + }, + { + "id": "def-common.kibanaContext.to", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.kibanaContext.to.null", + "type": "Function", + "children": [], + "signature": [ + "() => { type: string; }" + ], + "description": [], + "label": "null", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 44 + }, + "tags": [], + "returnComment": [] + } + ], + "description": [], + "label": "to", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 43 + } + } + ], + "description": [], + "label": "kibanaContext", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", + "lineNumber": 34 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kibanaContextFunction", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContextFunction.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 44 + }, + "signature": [ + "\"kibana_context\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 45 + }, + "signature": [ + "\"kibana_context\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 46 + }, + "signature": [ + "(\"kibana_context\" | \"null\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 47 + } + }, + { + "id": "def-common.kibanaContextFunction.args", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.kibanaContextFunction.args.q", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.q.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 52 + }, + "signature": [ + "(\"null\" | \"kibana_query\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.q.aliases", + "type": "Array", + "label": "aliases", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 53 + }, + "signature": [ + "string[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.q.default", + "type": "Uncategorized", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 54 + }, + "signature": [ + "null" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.q.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 55 + } + } + ], + "description": [], + "label": "q", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 51 + } + }, + { + "id": "def-common.kibanaContextFunction.args.filters", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.filters.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 60 + }, + "signature": [ + "(\"null\" | \"kibana_filter\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.filters.multi", + "type": "boolean", + "label": "multi", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 61 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.filters.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 62 + } + } + ], + "description": [], + "label": "filters", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 59 + } + }, + { + "id": "def-common.kibanaContextFunction.args.timeRange", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.timeRange.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 67 + }, + "signature": [ + "(\"null\" | \"timerange\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.timeRange.default", + "type": "Uncategorized", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 68 + }, + "signature": [ + "null" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.timeRange.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 69 + } + } + ], + "description": [], + "label": "timeRange", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 66 + } + }, + { + "id": "def-common.kibanaContextFunction.args.savedSearchId", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.savedSearchId.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 74 + }, + "signature": [ + "(\"string\" | \"null\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.savedSearchId.default", + "type": "Uncategorized", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 75 + }, + "signature": [ + "null" + ] + }, + { + "tags": [], + "id": "def-common.kibanaContextFunction.args.savedSearchId.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 76 + } + } + ], + "description": [], + "label": "savedSearchId", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 73 + } + } + ], + "description": [], + "label": "args", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 50 + } + }, + { + "id": "def-common.kibanaContextFunction.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: Input, args: Arguments, { getSavedObject }: ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ExecutionContextSearch", + "text": "ExecutionContextSearch" + }, + ">) => Promise<{ type: \"kibana_context\"; query: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataQueryPluginApi", + "section": "def-common.Query", + "text": "Query" + }, + "[]; filters: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.Filter", + "text": "Filter" + } + ], + "description": [], + "children": [ + { + "type": "CompoundType", + "label": "input", + "isRequired": false, + "signature": [ + "Input" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 82 + } + }, + { + "type": "Object", + "label": "args", + "isRequired": true, + "signature": [ + "Arguments" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 82 + } + }, + { + "type": "Object", + "label": "{ getSavedObject }", + "isRequired": true, + "signature": [ + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.ExecutionContextSearch", + "text": "ExecutionContextSearch" + }, + ">" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 82 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 82 + } + } + ], + "description": [], + "label": "kibanaContextFunction", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_context.ts", + "lineNumber": 43 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kibanaFilterFunction", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaFilterFunction.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 26 + }, + "signature": [ + "\"kibanaFilter\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 27 + }, + "signature": [ + "\"kibana_filter\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 28 + }, + "signature": [ + "\"null\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 29 + } + }, + { + "id": "def-common.kibanaFilterFunction.args", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.kibanaFilterFunction.args.query", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.query.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 34 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.query.aliases", + "type": "Array", + "label": "aliases", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 35 + }, + "signature": [ + "string[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.query.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 36 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.query.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 37 + } + } + ], + "description": [], + "label": "query", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 33 + } + }, + { + "id": "def-common.kibanaFilterFunction.args.negate", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.negate.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 42 + }, + "signature": [ + "\"boolean\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.negate.default", + "type": "boolean", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 43 + }, + "signature": [ + "false" + ] + }, + { + "tags": [], + "id": "def-common.kibanaFilterFunction.args.negate.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 44 + } + } + ], + "description": [], + "label": "negate", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 41 + } + } + ], + "description": [], + "label": "args", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 32 + } + }, + { + "id": "def-common.kibanaFilterFunction.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: null, args: Arguments) => any" + ], + "description": [], + "children": [ + { + "type": "Uncategorized", + "label": "input", + "isRequired": true, + "signature": [ + "null" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 50 + } + }, + { + "type": "Object", + "label": "args", + "isRequired": true, + "signature": [ + "Arguments" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 50 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 50 + } + } + ], + "description": [], + "label": "kibanaFilterFunction", + "source": { + "path": "src/plugins/data/common/search/expressions/kibana_filter.ts", + "lineNumber": 25 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kibanaTimerangeFunction", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 23 + }, + "signature": [ + "\"timerange\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 24 + }, + "signature": [ + "\"timerange\"" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 25 + }, + "signature": [ + "\"null\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 26 + } + }, + { + "id": "def-common.kibanaTimerangeFunction.args", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.kibanaTimerangeFunction.args.from", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.from.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 31 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.from.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 32 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.from.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 33 + } + } + ], + "description": [], + "label": "from", + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 30 + } + }, + { + "id": "def-common.kibanaTimerangeFunction.args.to", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.to.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 38 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.to.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 39 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.to.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 40 + } + } + ], + "description": [], + "label": "to", + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 37 + } + }, + { + "id": "def-common.kibanaTimerangeFunction.args.mode", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.mode.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 45 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.mode.options", + "type": "Array", + "label": "options", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 46 + }, + "signature": [ + "(\"absolute\" | \"relative\")[]" + ] + }, + { + "tags": [], + "id": "def-common.kibanaTimerangeFunction.args.mode.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 47 + } + } + ], + "description": [], + "label": "mode", + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 44 + } + } + ], + "description": [], + "label": "args", + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 29 + } + }, + { + "id": "def-common.kibanaTimerangeFunction.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: null, args: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataQueryPluginApi", + "section": "def-common.TimeRange", + "text": "TimeRange" + }, + ") => { type: \"timerange\"; from: string; to: string; mode: \"absolute\" | \"relative\" | undefined; }" + ], + "description": [], + "children": [ + { + "type": "Uncategorized", + "label": "input", + "isRequired": true, + "signature": [ + "null" ], "description": [], - "label": "null", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 34 - }, + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 53 + } + }, + { + "type": "Object", + "label": "args", + "isRequired": true, + "signature": [ + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataQueryPluginApi", + "section": "def-common.TimeRange", + "text": "TimeRange" + } + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 53 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 53 + } + } + ], + "description": [], + "label": "kibanaTimerangeFunction", + "source": { + "path": "src/plugins/data/common/search/expressions/timerange.ts", + "lineNumber": 22 + }, + "initialIsOpen": false + }, + { + "id": "def-common.kqlFunction", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.kqlFunction.name", + "type": "string", + "label": "name", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 25 + }, + "signature": [ + "\"kql\"" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.type", + "type": "string", + "label": "type", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 26 + }, + "signature": [ + "\"kibana_query\"" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.inputTypes", + "type": "Array", + "label": "inputTypes", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 27 + }, + "signature": [ + "\"null\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 28 + } + }, + { + "id": "def-common.kqlFunction.args", + "type": "Object", + "tags": [], + "children": [ + { + "id": "def-common.kqlFunction.args.q", + "type": "Object", "tags": [], - "returnComment": [] + "children": [ + { + "tags": [], + "id": "def-common.kqlFunction.args.q.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 33 + }, + "signature": [ + "\"string\"[]" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.args.q.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 34 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.args.q.aliases", + "type": "Array", + "label": "aliases", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 35 + }, + "signature": [ + "string[]" + ] + }, + { + "tags": [], + "id": "def-common.kqlFunction.args.q.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 36 + } + } + ], + "description": [], + "label": "q", + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 32 + } } ], "description": [], - "label": "from", + "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 33 + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 31 } }, { - "id": "def-common.kibanaContext.to", - "type": "Object", - "tags": [], + "id": "def-common.kqlFunction.fn", + "type": "Function", + "label": "fn", + "signature": [ + "(input: null, args: Arguments) => { type: \"kibana_query\"; language: string; query: string; }" + ], + "description": [], "children": [ { - "id": "def-common.kibanaContext.to.null", - "type": "Function", - "children": [], + "type": "Uncategorized", + "label": "input", + "isRequired": true, "signature": [ - "() => { type: string; }" + "null" ], "description": [], - "label": "null", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 41 - }, - "tags": [], - "returnComment": [] + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 42 + } + }, + { + "type": "Object", + "label": "args", + "isRequired": true, + "signature": [ + "Arguments" + ], + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 42 + } } ], - "description": [], - "label": "to", + "tags": [], + "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 40 + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 42 } } ], "description": [], - "label": "kibanaContext", + "label": "kqlFunction", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context_type.ts", - "lineNumber": 31 + "path": "src/plugins/data/common/search/expressions/kql.ts", + "lineNumber": 24 }, "initialIsOpen": false }, { - "id": "def-common.kibanaContextFunction", + "id": "def-common.luceneFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaContextFunction.name", + "id": "def-common.luceneFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 43 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 25 }, "signature": [ - "\"kibana_context\"" + "\"lucene\"" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.type", + "id": "def-common.luceneFunction.type", "type": "string", "label": "type", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 44 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 26 }, "signature": [ - "\"kibana_context\"" + "\"kibana_query\"" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.inputTypes", + "id": "def-common.luceneFunction.inputTypes", "type": "Array", "label": "inputTypes", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 45 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 27 }, "signature": [ - "(\"kibana_context\" | \"null\")[]" + "\"null\"[]" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.help", + "id": "def-common.luceneFunction.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 46 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 28 } }, { - "id": "def-common.kibanaContextFunction.args", + "id": "def-common.luceneFunction.args", "type": "Object", "tags": [], "children": [ { - "id": "def-common.kibanaContextFunction.args.q", - "type": "Object", - "tags": [], - "children": [ - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.q.types", - "type": "Array", - "label": "types", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 51 - }, - "signature": [ - "(\"null\" | \"kibana_query\")[]" - ] - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.q.aliases", - "type": "Array", - "label": "aliases", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 52 - }, - "signature": [ - "string[]" - ] - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.q.default", - "type": "Uncategorized", - "label": "default", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 53 - }, - "signature": [ - "null" - ] - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.q.help", - "type": "string", - "label": "help", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 54 - } - } - ], - "description": [], - "label": "q", - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 50 - } - }, - { - "id": "def-common.kibanaContextFunction.args.filters", - "type": "Object", - "tags": [], - "children": [ - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.filters.types", - "type": "Array", - "label": "types", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 59 - }, - "signature": [ - "(\"string\" | \"null\")[]" - ] - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.filters.default", - "type": "string", - "label": "default", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 60 - } - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.filters.help", - "type": "string", - "label": "help", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 61 - } - } - ], - "description": [], - "label": "filters", - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 58 - } - }, - { - "id": "def-common.kibanaContextFunction.args.timeRange", + "id": "def-common.luceneFunction.args.q", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaContextFunction.args.timeRange.types", + "id": "def-common.luceneFunction.args.q.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 66 - }, - "signature": [ - "(\"null\" | \"timerange\")[]" - ] - }, - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.timeRange.default", - "type": "Uncategorized", - "label": "default", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 67 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 33 }, "signature": [ - "null" + "\"string\"[]" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.args.timeRange.help", - "type": "string", - "label": "help", - "description": [], - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 68 - } - } - ], - "description": [], - "label": "timeRange", - "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 65 - } - }, - { - "id": "def-common.kibanaContextFunction.args.savedSearchId", - "type": "Object", - "tags": [], - "children": [ - { - "tags": [], - "id": "def-common.kibanaContextFunction.args.savedSearchId.types", - "type": "Array", - "label": "types", + "id": "def-common.luceneFunction.args.q.required", + "type": "boolean", + "label": "required", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 73 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 34 }, "signature": [ - "(\"string\" | \"null\")[]" + "true" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.args.savedSearchId.default", - "type": "Uncategorized", - "label": "default", + "id": "def-common.luceneFunction.args.q.aliases", + "type": "Array", + "label": "aliases", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 74 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 35 }, "signature": [ - "null" + "string[]" ] }, { "tags": [], - "id": "def-common.kibanaContextFunction.args.savedSearchId.help", + "id": "def-common.luceneFunction.args.q.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 75 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 36 } } ], "description": [], - "label": "savedSearchId", + "label": "q", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 72 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 32 } } ], "description": [], "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 49 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 31 } }, { - "id": "def-common.kibanaContextFunction.fn", + "id": "def-common.luceneFunction.fn", "type": "Function", "label": "fn", "signature": [ - "(input: Input, args: Arguments, { getSavedObject }: ", - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExecutionContext", - "text": "ExecutionContext" - }, - "<", - { - "pluginId": "inspector", - "scope": "common", - "docId": "kibInspectorPluginApi", - "section": "def-common.Adapters", - "text": "Adapters" - }, - ", ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.ExecutionContextSearch", - "text": "ExecutionContextSearch" - }, - ">) => Promise<{ type: \"kibana_context\"; query: ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataQueryPluginApi", - "section": "def-common.Query", - "text": "Query" - }, - "[]; filters: ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataPluginApi", - "section": "def-common.Filter", - "text": "Filter" - } + "(input: null, args: Arguments) => { type: \"kibana_query\"; language: string; query: any; }" ], "description": [], "children": [ { - "type": "CompoundType", + "type": "Uncategorized", "label": "input", - "isRequired": false, + "isRequired": true, "signature": [ - "Input" + "null" ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 81 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 42 } }, { @@ -19892,105 +21960,201 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 81 + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 42 } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 42 + } + } + ], + "description": [], + "label": "luceneFunction", + "source": { + "path": "src/plugins/data/common/search/expressions/lucene.ts", + "lineNumber": 24 + }, + "initialIsOpen": false + }, + { + "tags": [], + "id": "def-common.migrateIncludeExcludeFormat", + "type": "Object", + "label": "migrateIncludeExcludeFormat", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/buckets/migrate_include_exclude_format.ts", + "lineNumber": 25 + }, + "signature": [ + "Partial<", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.BucketAggParam", + "text": "BucketAggParam" + }, + "<", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.IBucketAggConfig", + "text": "IBucketAggConfig" + }, + ">>" + ], + "initialIsOpen": false + }, + { + "id": "def-common.parentPipelineAggHelper", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.parentPipelineAggHelper.subtype", + "type": "string", + "label": "subtype", + "description": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 35 + } + }, + { + "id": "def-common.parentPipelineAggHelper.params", + "type": "Function", + "label": "params", + "signature": [ + "() => ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.MetricAggParam", + "text": "MetricAggParam" + }, + "<", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.IMetricAggConfig", + "text": "IMetricAggConfig" + }, + ">[]" + ], + "description": [], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 36 + } + }, + { + "id": "def-common.parentPipelineAggHelper.getSerializedFormat", + "type": "Function", + "label": "getSerializedFormat", + "signature": [ + "(agg: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataSearchPluginApi", + "section": "def-common.IMetricAggConfig", + "text": "IMetricAggConfig" }, + ") => any" + ], + "description": [], + "children": [ { "type": "Object", - "label": "{ getSavedObject }", + "label": "agg", "isRequired": true, "signature": [ - { - "pluginId": "expressions", - "scope": "common", - "docId": "kibExpressionsPluginApi", - "section": "def-common.ExecutionContext", - "text": "ExecutionContext" - }, - "<", - { - "pluginId": "inspector", - "scope": "common", - "docId": "kibInspectorPluginApi", - "section": "def-common.Adapters", - "text": "Adapters" - }, - ", ", { "pluginId": "data", "scope": "common", "docId": "kibDataSearchPluginApi", - "section": "def-common.ExecutionContextSearch", - "text": "ExecutionContextSearch" - }, - ">" + "section": "def-common.IMetricAggConfig", + "text": "IMetricAggConfig" + } ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 81 + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 64 } } ], "tags": [], "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 81 + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 64 } } ], "description": [], - "label": "kibanaContextFunction", + "label": "parentPipelineAggHelper", "source": { - "path": "src/plugins/data/common/search/expressions/kibana_context.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", + "lineNumber": 34 }, "initialIsOpen": false }, { - "id": "def-common.kibanaTimerangeFunction", + "id": "def-common.phraseFilterFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaTimerangeFunction.name", + "id": "def-common.phraseFilterFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 23 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 29 }, "signature": [ - "\"timerange\"" + "\"rangeFilter\"" ] }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.type", + "id": "def-common.phraseFilterFunction.type", "type": "string", "label": "type", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 24 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 30 }, "signature": [ - "\"timerange\"" + "\"kibana_filter\"" ] }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.inputTypes", + "id": "def-common.phraseFilterFunction.inputTypes", "type": "Array", "label": "inputTypes", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 25 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 31 }, "signature": [ "\"null\"[]" @@ -19998,48 +22162,48 @@ }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.help", + "id": "def-common.phraseFilterFunction.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 26 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 32 } }, { - "id": "def-common.kibanaTimerangeFunction.args", + "id": "def-common.phraseFilterFunction.args", "type": "Object", "tags": [], "children": [ { - "id": "def-common.kibanaTimerangeFunction.args.from", + "id": "def-common.phraseFilterFunction.args.field", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.from.types", + "id": "def-common.phraseFilterFunction.args.field.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 31 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 37 }, "signature": [ - "\"string\"[]" + "\"kibana_field\"[]" ] }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.from.required", + "id": "def-common.phraseFilterFunction.args.field.required", "type": "boolean", "label": "required", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 32 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 38 }, "signature": [ "true" @@ -20047,37 +22211,37 @@ }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.from.help", + "id": "def-common.phraseFilterFunction.args.field.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 33 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 39 } } ], "description": [], - "label": "from", + "label": "field", "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 30 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 36 } }, { - "id": "def-common.kibanaTimerangeFunction.args.to", + "id": "def-common.phraseFilterFunction.args.phrase", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.to.types", + "id": "def-common.phraseFilterFunction.args.phrase.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 38 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 44 }, "signature": [ "\"string\"[]" @@ -20085,13 +22249,27 @@ }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.to.required", + "id": "def-common.phraseFilterFunction.args.phrase.multi", + "type": "boolean", + "label": "multi", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 45 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.phraseFilterFunction.args.phrase.required", "type": "boolean", "label": "required", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 39 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 46 }, "signature": [ "true" @@ -20099,97 +22277,105 @@ }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.to.help", + "id": "def-common.phraseFilterFunction.args.phrase.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 40 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 47 } } ], "description": [], - "label": "to", + "label": "phrase", "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 37 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 43 } }, { - "id": "def-common.kibanaTimerangeFunction.args.mode", + "id": "def-common.phraseFilterFunction.args.negate", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.mode.types", + "id": "def-common.phraseFilterFunction.args.negate.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 45 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 52 }, "signature": [ - "\"string\"[]" + "\"boolean\"[]" ] }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.mode.options", - "type": "Array", - "label": "options", + "id": "def-common.phraseFilterFunction.args.negate.default", + "type": "boolean", + "label": "default", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 46 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 53 }, "signature": [ - "(\"absolute\" | \"relative\")[]" + "false" ] }, { "tags": [], - "id": "def-common.kibanaTimerangeFunction.args.mode.help", + "id": "def-common.phraseFilterFunction.args.negate.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 47 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 54 } } ], "description": [], - "label": "mode", + "label": "negate", "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 44 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 51 } } ], "description": [], "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 29 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 35 } }, { - "id": "def-common.kibanaTimerangeFunction.fn", + "id": "def-common.phraseFilterFunction.fn", "type": "Function", "label": "fn", "signature": [ - "(input: null, args: ", + "(input: null, args: Arguments) => { $state?: ", { "pluginId": "data", "scope": "common", - "docId": "kibDataQueryPluginApi", - "section": "def-common.TimeRange", - "text": "TimeRange" + "docId": "kibDataPluginApi", + "section": "def-common.FilterState", + "text": "FilterState" }, - ") => { type: \"timerange\"; from: string; to: string; mode: \"absolute\" | \"relative\" | undefined; }" + " | undefined; meta: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.FilterMeta", + "text": "FilterMeta" + }, + "; query?: any; type: \"kibana_filter\"; }" ], "description": [], "children": [ @@ -20202,8 +22388,8 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 53 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 60 } }, { @@ -20211,79 +22397,73 @@ "label": "args", "isRequired": true, "signature": [ - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataQueryPluginApi", - "section": "def-common.TimeRange", - "text": "TimeRange" - } + "Arguments" ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 53 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 60 } } ], "tags": [], "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 53 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 60 } } ], "description": [], - "label": "kibanaTimerangeFunction", + "label": "phraseFilterFunction", "source": { - "path": "src/plugins/data/common/search/expressions/timerange.ts", - "lineNumber": 22 + "path": "src/plugins/data/common/search/expressions/phrase_filter.ts", + "lineNumber": 28 }, "initialIsOpen": false }, { - "id": "def-common.kqlFunction", + "id": "def-common.rangeFilterFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kqlFunction.name", + "id": "def-common.rangeFilterFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 25 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 30 }, "signature": [ - "\"kql\"" + "\"rangeFilter\"" ] }, { "tags": [], - "id": "def-common.kqlFunction.type", + "id": "def-common.rangeFilterFunction.type", "type": "string", "label": "type", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 26 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 31 }, "signature": [ - "\"kibana_query\"" + "\"kibana_filter\"" ] }, { "tags": [], - "id": "def-common.kqlFunction.inputTypes", + "id": "def-common.rangeFilterFunction.inputTypes", "type": "Array", "label": "inputTypes", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 27 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 32 }, "signature": [ "\"null\"[]" @@ -20291,48 +22471,48 @@ }, { "tags": [], - "id": "def-common.kqlFunction.help", + "id": "def-common.rangeFilterFunction.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 28 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 33 } }, { - "id": "def-common.kqlFunction.args", + "id": "def-common.rangeFilterFunction.args", "type": "Object", "tags": [], "children": [ { - "id": "def-common.kqlFunction.args.q", + "id": "def-common.rangeFilterFunction.args.field", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.kqlFunction.args.q.types", + "id": "def-common.rangeFilterFunction.args.field.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 33 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 38 }, "signature": [ - "\"string\"[]" + "\"kibana_field\"[]" ] }, { "tags": [], - "id": "def-common.kqlFunction.args.q.required", + "id": "def-common.rangeFilterFunction.args.field.required", "type": "boolean", "label": "required", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 34 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 39 }, "signature": [ "true" @@ -20340,51 +22520,157 @@ }, { "tags": [], - "id": "def-common.kqlFunction.args.q.aliases", + "id": "def-common.rangeFilterFunction.args.field.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 40 + } + } + ], + "description": [], + "label": "field", + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 37 + } + }, + { + "id": "def-common.rangeFilterFunction.args.range", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.rangeFilterFunction.args.range.types", "type": "Array", - "label": "aliases", + "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 35 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 45 }, "signature": [ - "string[]" + "\"kibana_range\"[]" ] }, { "tags": [], - "id": "def-common.kqlFunction.args.q.help", + "id": "def-common.rangeFilterFunction.args.range.required", + "type": "boolean", + "label": "required", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 46 + }, + "signature": [ + "true" + ] + }, + { + "tags": [], + "id": "def-common.rangeFilterFunction.args.range.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 36 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 47 } } ], "description": [], - "label": "q", + "label": "range", "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 32 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 44 + } + }, + { + "id": "def-common.rangeFilterFunction.args.negate", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.rangeFilterFunction.args.negate.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 52 + }, + "signature": [ + "\"boolean\"[]" + ] + }, + { + "tags": [], + "id": "def-common.rangeFilterFunction.args.negate.default", + "type": "boolean", + "label": "default", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 53 + }, + "signature": [ + "false" + ] + }, + { + "tags": [], + "id": "def-common.rangeFilterFunction.args.negate.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 54 + } + } + ], + "description": [], + "label": "negate", + "source": { + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 51 } } ], "description": [], "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 31 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 36 } }, { - "id": "def-common.kqlFunction.fn", + "id": "def-common.rangeFilterFunction.fn", "type": "Function", "label": "fn", "signature": [ - "(input: null, args: Arguments) => { type: \"kibana_query\"; language: string; query: string; }" + "(input: null, args: Arguments) => { $state?: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.FilterState", + "text": "FilterState" + }, + " | undefined; meta: ", + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataPluginApi", + "section": "def-common.FilterMeta", + "text": "FilterMeta" + }, + "; query?: any; type: \"kibana_filter\"; }" ], "description": [], "children": [ @@ -20397,8 +22683,8 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 60 } }, { @@ -20410,69 +22696,69 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 60 } } ], "tags": [], "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 60 } } ], "description": [], - "label": "kqlFunction", + "label": "rangeFilterFunction", "source": { - "path": "src/plugins/data/common/search/expressions/kql.ts", - "lineNumber": 24 + "path": "src/plugins/data/common/search/expressions/range_filter.ts", + "lineNumber": 29 }, "initialIsOpen": false }, { - "id": "def-common.luceneFunction", + "id": "def-common.rangeFunction", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.luceneFunction.name", + "id": "def-common.rangeFunction.name", "type": "string", "label": "name", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 25 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 29 }, "signature": [ - "\"lucene\"" + "\"range\"" ] }, { "tags": [], - "id": "def-common.luceneFunction.type", + "id": "def-common.rangeFunction.type", "type": "string", "label": "type", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 26 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 30 }, "signature": [ - "\"kibana_query\"" + "\"kibana_range\"" ] }, { "tags": [], - "id": "def-common.luceneFunction.inputTypes", + "id": "def-common.rangeFunction.inputTypes", "type": "Array", "label": "inputTypes", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 27 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 31 }, "signature": [ "\"null\"[]" @@ -20480,100 +22766,186 @@ }, { "tags": [], - "id": "def-common.luceneFunction.help", + "id": "def-common.rangeFunction.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 28 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 32 } }, { - "id": "def-common.luceneFunction.args", + "id": "def-common.rangeFunction.args", "type": "Object", "tags": [], "children": [ { - "id": "def-common.luceneFunction.args.q", + "id": "def-common.rangeFunction.args.gt", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.luceneFunction.args.q.types", + "id": "def-common.rangeFunction.args.gt.types", "type": "Array", "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 33 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 37 }, "signature": [ - "\"string\"[]" + "(\"string\" | \"number\")[]" ] }, { "tags": [], - "id": "def-common.luceneFunction.args.q.required", - "type": "boolean", - "label": "required", + "id": "def-common.rangeFunction.args.gt.help", + "type": "string", + "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 34 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 38 + } + } + ], + "description": [], + "label": "gt", + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 36 + } + }, + { + "id": "def-common.rangeFunction.args.lt", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.rangeFunction.args.lt.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 43 }, "signature": [ - "true" + "(\"string\" | \"number\")[]" ] }, { "tags": [], - "id": "def-common.luceneFunction.args.q.aliases", + "id": "def-common.rangeFunction.args.lt.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 44 + } + } + ], + "description": [], + "label": "lt", + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 42 + } + }, + { + "id": "def-common.rangeFunction.args.gte", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.rangeFunction.args.gte.types", "type": "Array", - "label": "aliases", + "label": "types", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 35 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 49 }, "signature": [ - "string[]" + "(\"string\" | \"number\")[]" ] }, { "tags": [], - "id": "def-common.luceneFunction.args.q.help", + "id": "def-common.rangeFunction.args.gte.help", "type": "string", "label": "help", "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 36 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 50 } } ], "description": [], - "label": "q", + "label": "gte", "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 32 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 48 + } + }, + { + "id": "def-common.rangeFunction.args.lte", + "type": "Object", + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.rangeFunction.args.lte.types", + "type": "Array", + "label": "types", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 55 + }, + "signature": [ + "(\"string\" | \"number\")[]" + ] + }, + { + "tags": [], + "id": "def-common.rangeFunction.args.lte.help", + "type": "string", + "label": "help", + "description": [], + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 56 + } + } + ], + "description": [], + "label": "lte", + "source": { + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 54 } } ], "description": [], "label": "args", "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 31 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 35 } }, { - "id": "def-common.luceneFunction.fn", + "id": "def-common.rangeFunction.fn", "type": "Function", "label": "fn", "signature": [ - "(input: null, args: Arguments) => { type: \"kibana_query\"; language: string; query: any; }" + "(input: null, args: Arguments) => { gt?: string | number | undefined; lt?: string | number | undefined; gte?: string | number | undefined; lte?: string | number | undefined; type: \"kibana_range\"; }" ], "description": [], "children": [ @@ -20586,8 +22958,8 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 62 } }, { @@ -20599,80 +22971,49 @@ ], "description": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 62 } } ], "tags": [], "returnComment": [], "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 42 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 62 } } ], "description": [], - "label": "luceneFunction", - "source": { - "path": "src/plugins/data/common/search/expressions/lucene.ts", - "lineNumber": 24 - }, - "initialIsOpen": false - }, - { - "tags": [], - "id": "def-common.migrateIncludeExcludeFormat", - "type": "Object", - "label": "migrateIncludeExcludeFormat", - "description": [], + "label": "rangeFunction", "source": { - "path": "src/plugins/data/common/search/aggs/buckets/migrate_include_exclude_format.ts", - "lineNumber": 25 + "path": "src/plugins/data/common/search/expressions/range.ts", + "lineNumber": 28 }, - "signature": [ - "Partial<", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.BucketAggParam", - "text": "BucketAggParam" - }, - "<", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.IBucketAggConfig", - "text": "IBucketAggConfig" - }, - ">>" - ], "initialIsOpen": false }, { - "id": "def-common.parentPipelineAggHelper", + "id": "def-common.siblingPipelineAggHelper", "type": "Object", "tags": [], "children": [ { "tags": [], - "id": "def-common.parentPipelineAggHelper.subtype", + "id": "def-common.siblingPipelineAggHelper.subtype", "type": "string", "label": "subtype", "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", - "lineNumber": 34 + "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", + "lineNumber": 42 } }, { - "id": "def-common.parentPipelineAggHelper.params", + "id": "def-common.siblingPipelineAggHelper.params", "type": "Function", "label": "params", "signature": [ - "() => ", + "(bucketFilter?: string[]) => ", { "pluginId": "data", "scope": "common", @@ -20691,113 +23032,26 @@ ">[]" ], "description": [], - "children": [], - "tags": [], - "returnComment": [], - "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", - "lineNumber": 35 - } - }, - { - "id": "def-common.parentPipelineAggHelper.getSerializedFormat", - "type": "Function", - "label": "getSerializedFormat", - "signature": [ - "(agg: ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.IMetricAggConfig", - "text": "IMetricAggConfig" - }, - ") => any" - ], - "description": [], "children": [ { - "type": "Object", - "label": "agg", + "type": "Array", + "label": "bucketFilter", "isRequired": true, "signature": [ - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.IMetricAggConfig", - "text": "IMetricAggConfig" - } + "string[]" ], "description": [], "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", - "lineNumber": 63 + "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", + "lineNumber": 43 } } ], "tags": [], "returnComment": [], - "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", - "lineNumber": 63 - } - } - ], - "description": [], - "label": "parentPipelineAggHelper", - "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts", - "lineNumber": 33 - }, - "initialIsOpen": false - }, - { - "id": "def-common.siblingPipelineAggHelper", - "type": "Object", - "tags": [], - "children": [ - { - "tags": [], - "id": "def-common.siblingPipelineAggHelper.subtype", - "type": "string", - "label": "subtype", - "description": [], - "source": { - "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 41 - } - }, - { - "id": "def-common.siblingPipelineAggHelper.params", - "type": "Function", - "label": "params", - "signature": [ - "() => ", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.MetricAggParam", - "text": "MetricAggParam" - }, - "<", - { - "pluginId": "data", - "scope": "common", - "docId": "kibDataSearchPluginApi", - "section": "def-common.IMetricAggConfig", - "text": "IMetricAggConfig" - }, - ">[]" - ], - "description": [], - "children": [], - "tags": [], - "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 42 + "lineNumber": 43 } }, { @@ -20833,7 +23087,7 @@ "description": [], "source": { "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 77 + "lineNumber": 79 } } ], @@ -20841,7 +23095,7 @@ "returnComment": [], "source": { "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 77 + "lineNumber": 79 } } ], @@ -20849,7 +23103,7 @@ "label": "siblingPipelineAggHelper", "source": { "path": "src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts", - "lineNumber": 40 + "lineNumber": 41 }, "initialIsOpen": false } diff --git a/api_docs/expressions.json b/api_docs/expressions.json index f80d9b6c4cdd3..eefffb009be2a 100644 --- a/api_docs/expressions.json +++ b/api_docs/expressions.json @@ -21952,6 +21952,41 @@ "returnComment": [], "initialIsOpen": false }, + { + "id": "def-common.createMockContext", + "type": "Function", + "children": [], + "signature": [ + "() => ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExecutionContext", + "text": "ExecutionContext" + }, + "<", + { + "pluginId": "inspector", + "scope": "common", + "docId": "kibInspectorPluginApi", + "section": "def-common.Adapters", + "text": "Adapters" + }, + ", ", + "SerializableState", + ">" + ], + "description": [], + "label": "createMockContext", + "source": { + "path": "src/plugins/expressions/common/util/test_utils.ts", + "lineNumber": 11 + }, + "tags": [], + "returnComment": [], + "initialIsOpen": false + }, { "id": "def-common.format", "type": "Function", @@ -22465,6 +22500,52 @@ "tags": [], "returnComment": [], "initialIsOpen": false + }, + { + "id": "def-common.unboxExpressionValue", + "type": "Function", + "label": "unboxExpressionValue", + "signature": [ + "({\n type,\n ...value\n}: ", + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + ") => T" + ], + "description": [], + "children": [ + { + "type": "CompoundType", + "label": "{\n type,\n ...value\n}", + "isRequired": true, + "signature": [ + { + "pluginId": "expressions", + "scope": "common", + "docId": "kibExpressionsPluginApi", + "section": "def-common.ExpressionValueBoxed", + "text": "ExpressionValueBoxed" + }, + "" + ], + "description": [], + "source": { + "path": "src/plugins/expressions/common/expression_types/unbox_expression_value.ts", + "lineNumber": 11 + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "src/plugins/expressions/common/expression_types/unbox_expression_value.ts", + "lineNumber": 11 + }, + "initialIsOpen": false } ], "interfaces": [ diff --git a/api_docs/fleet.json b/api_docs/fleet.json index d9774d14e4c96..ed51f88ee9d5d 100644 --- a/api_docs/fleet.json +++ b/api_docs/fleet.json @@ -2515,7 +2515,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 94 + "lineNumber": 95 }, "signature": [ { @@ -2535,7 +2535,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 95 + "lineNumber": 96 }, "signature": [ { @@ -2556,7 +2556,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 96 + "lineNumber": 97 }, "signature": [ { @@ -2577,7 +2577,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 97 + "lineNumber": 98 }, "signature": [ { @@ -2597,7 +2597,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 98 + "lineNumber": 99 }, "signature": [ { @@ -2618,7 +2618,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 99 + "lineNumber": 100 }, "signature": [ "Pick<", @@ -2635,7 +2635,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 93 + "lineNumber": 94 }, "initialIsOpen": false }, @@ -2735,7 +2735,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 137 + "lineNumber": 140 }, "signature": [ "[\"packagePolicyCreate\", (newPackagePolicy: ", @@ -2837,7 +2837,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 122 + "lineNumber": 125 }, "signature": [ "void" @@ -2862,7 +2862,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 161 + "lineNumber": 164 }, "signature": [ { @@ -2882,7 +2882,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 162 + "lineNumber": 165 }, "signature": [ { @@ -2902,7 +2902,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 163 + "lineNumber": 166 }, "signature": [ { @@ -2924,7 +2924,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 167 + "lineNumber": 170 }, "signature": [ { @@ -2944,7 +2944,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 168 + "lineNumber": 171 }, "signature": [ { @@ -2966,7 +2966,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 173 + "lineNumber": 176 }, "signature": [ "(...args: ", @@ -2990,7 +2990,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 179 + "lineNumber": 182 }, "signature": [ "(packageName: string) => ", @@ -3006,7 +3006,7 @@ ], "source": { "path": "x-pack/plugins/fleet/server/plugin.ts", - "lineNumber": 160 + "lineNumber": 163 }, "lifecycle": "start", "initialIsOpen": true @@ -3274,7 +3274,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/index.ts", - "lineNumber": 38 + "lineNumber": 39 }, "signature": [ "(o: T) => [keyof T, T[keyof T]][]" @@ -4486,59 +4486,44 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 86 + "lineNumber": 91 } }, { "tags": [], - "id": "def-common.BulkInstallPackageInfo.newVersion", + "id": "def-common.BulkInstallPackageInfo.version", "type": "string", - "label": "newVersion", + "label": "version", "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 87 + "lineNumber": 92 } }, { "tags": [], - "id": "def-common.BulkInstallPackageInfo.oldVersion", - "type": "CompoundType", - "label": "oldVersion", - "description": [], - "source": { - "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 89 - }, - "signature": [ - "string | null" - ] - }, - { - "tags": [], - "id": "def-common.BulkInstallPackageInfo.assets", - "type": "Array", - "label": "assets", + "id": "def-common.BulkInstallPackageInfo.result", + "type": "Object", + "label": "result", "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 90 + "lineNumber": 93 }, "signature": [ { "pluginId": "fleet", "scope": "common", "docId": "kibFleetPluginApi", - "section": "def-common.AssetReference", - "text": "AssetReference" - }, - "[]" + "section": "def-common.InstallResult", + "text": "InstallResult" + } ] } ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 85 + "lineNumber": 90 }, "initialIsOpen": false }, @@ -4557,7 +4542,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 98 + "lineNumber": 101 }, "signature": [ "{ packages: string[]; }" @@ -4566,7 +4551,7 @@ ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 97 + "lineNumber": 100 }, "initialIsOpen": false }, @@ -4585,7 +4570,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 94 + "lineNumber": 97 }, "signature": [ "(", @@ -4610,7 +4595,7 @@ ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 93 + "lineNumber": 96 }, "initialIsOpen": false }, @@ -5235,7 +5220,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 108 + "lineNumber": 111 }, "signature": [ "{ pkgkey: string; }" @@ -5244,7 +5229,7 @@ ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 107 + "lineNumber": 110 }, "initialIsOpen": false }, @@ -5263,7 +5248,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 114 + "lineNumber": 117 }, "signature": [ { @@ -5279,7 +5264,7 @@ ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 113 + "lineNumber": 116 }, "initialIsOpen": false }, @@ -5507,7 +5492,7 @@ "lineNumber": 15 }, "signature": [ - "{ enabled: boolean; tlsCheckDisabled: boolean; pollingRequestTimeout: number; maxConcurrentConnections: number; kibana: { host?: string | string[] | undefined; ca_sha256?: string | undefined; }; elasticsearch: { host?: string | undefined; ca_sha256?: string | undefined; }; agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }" + "{ enabled: boolean; fleetServerEnabled: boolean; tlsCheckDisabled: boolean; pollingRequestTimeout: number; maxConcurrentConnections: number; kibana: { host?: string | string[] | undefined; ca_sha256?: string | undefined; }; elasticsearch: { host?: string | undefined; ca_sha256?: string | undefined; }; agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }" ] } ], @@ -8609,6 +8594,55 @@ }, "initialIsOpen": false }, + { + "id": "def-common.InstallResult", + "type": "Interface", + "label": "InstallResult", + "description": [], + "tags": [], + "children": [ + { + "tags": [], + "id": "def-common.InstallResult.assets", + "type": "Array", + "label": "assets", + "description": [], + "source": { + "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", + "lineNumber": 86 + }, + "signature": [ + { + "pluginId": "fleet", + "scope": "common", + "docId": "kibFleetPluginApi", + "section": "def-common.AssetReference", + "text": "AssetReference" + }, + "[]" + ] + }, + { + "tags": [], + "id": "def-common.InstallResult.status", + "type": "CompoundType", + "label": "status", + "description": [], + "source": { + "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", + "lineNumber": 87 + }, + "signature": [ + "\"installed\" | \"already_installed\"" + ] + } + ], + "source": { + "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", + "lineNumber": 85 + }, + "initialIsOpen": false + }, { "id": "def-common.InstallScriptRequest", "type": "Interface", @@ -8824,13 +8858,13 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 104 + "lineNumber": 107 } } ], "source": { "path": "x-pack/plugins/fleet/common/types/rest_spec/epm.ts", - "lineNumber": 103 + "lineNumber": 106 }, "initialIsOpen": false }, @@ -13209,7 +13243,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 13 + "lineNumber": 12 }, "signature": [ "\"fleet_server\"" @@ -13246,21 +13280,6 @@ ], "initialIsOpen": false }, - { - "tags": [], - "id": "def-common.INDEX_PATTERN_SAVED_OBJECT_TYPE", - "type": "string", - "label": "INDEX_PATTERN_SAVED_OBJECT_TYPE", - "description": [], - "source": { - "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 10 - }, - "signature": [ - "\"index-pattern\"" - ], - "initialIsOpen": false - }, { "tags": [], "id": "def-common.INSTALL_SCRIPT_API_ROUTES", @@ -13460,7 +13479,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 11 + "lineNumber": 10 }, "signature": [ "60000" @@ -14087,7 +14106,7 @@ ], "source": { "path": "x-pack/plugins/fleet/common/types/index.ts", - "lineNumber": 43 + "lineNumber": 44 }, "signature": [ "T[keyof T]" @@ -14443,7 +14462,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 24 + "lineNumber": 23 }, "signature": [ "{ readonly Input: \"input\"; }" @@ -15151,7 +15170,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 28 + "lineNumber": 27 }, "signature": [ "{ readonly Logs: \"logs\"; readonly Metrics: \"metrics\"; }" @@ -15481,7 +15500,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 22 + "lineNumber": 21 }, "signature": [ "{ readonly System: \"system\"; readonly Endpoint: \"endpoint\"; readonly ElasticAgent: \"elastic_agent\"; }" @@ -16058,7 +16077,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 33 + "lineNumber": 32 }, "signature": [ "{ readonly Installed: \"installed\"; readonly NotInstalled: \"not_installed\"; }" @@ -16416,7 +16435,7 @@ "description": [], "source": { "path": "x-pack/plugins/fleet/common/constants/epm.ts", - "lineNumber": 15 + "lineNumber": 14 }, "signature": [ "{ readonly System: \"system\"; readonly Endpoint: \"endpoint\"; readonly ElasticAgent: \"elastic_agent\"; }" diff --git a/api_docs/lens.json b/api_docs/lens.json index abebd217ad7d5..e586016c22fc3 100644 --- a/api_docs/lens.json +++ b/api_docs/lens.json @@ -288,7 +288,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts", - "lineNumber": 44 + "lineNumber": 46 }, "signature": [ "\"range\" | \"filters\" | \"count\" | \"max\" | \"min\" | \"date_histogram\" | \"sum\" | \"terms\" | \"avg\" | \"median\" | \"cumulative_sum\" | \"derivative\" | \"moving_average\" | \"counter_rate\" | \"cardinality\" | \"percentile\" | \"last_value\" | undefined" @@ -302,7 +302,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts", - "lineNumber": 45 + "lineNumber": 47 }, "signature": [ "string | undefined" @@ -311,7 +311,7 @@ ], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts", - "lineNumber": 43 + "lineNumber": 45 }, "initialIsOpen": false }, @@ -1318,7 +1318,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx", - "lineNumber": 125 + "lineNumber": 127 }, "signature": [ "BaseIndexPatternColumn & { params?: { format?: { id: string; params?: { decimals: number; } | undefined; } | undefined; } | undefined; } & FieldBasedIndexPatternColumn & { operationType: \"avg\"; }" @@ -1475,7 +1475,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx", - "lineNumber": 127 + "lineNumber": 129 }, "signature": [ "BaseIndexPatternColumn & { params?: { format?: { id: string; params?: { decimals: number; } | undefined; } | undefined; } | undefined; } & FieldBasedIndexPatternColumn & { operationType: \"max\"; }" @@ -1490,7 +1490,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx", - "lineNumber": 128 + "lineNumber": 130 }, "signature": [ "BaseIndexPatternColumn & { params?: { format?: { id: string; params?: { decimals: number; } | undefined; } | undefined; } | undefined; } & FieldBasedIndexPatternColumn & { operationType: \"median\"; }" @@ -1505,7 +1505,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx", - "lineNumber": 126 + "lineNumber": 128 }, "signature": [ "BaseIndexPatternColumn & { params?: { format?: { id: string; params?: { decimals: number; } | undefined; } | undefined; } | undefined; } & FieldBasedIndexPatternColumn & { operationType: \"min\"; }" @@ -1538,7 +1538,7 @@ ], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts", - "lineNumber": 405 + "lineNumber": 406 }, "signature": [ "\"range\" | \"filters\" | \"count\" | \"max\" | \"min\" | \"date_histogram\" | \"sum\" | \"terms\" | \"avg\" | \"median\" | \"cumulative_sum\" | \"derivative\" | \"moving_average\" | \"counter_rate\" | \"cardinality\" | \"percentile\" | \"last_value\"" @@ -1598,7 +1598,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx", - "lineNumber": 124 + "lineNumber": 126 }, "signature": [ "BaseIndexPatternColumn & { params?: { format?: { id: string; params?: { decimals: number; } | undefined; } | undefined; } | undefined; } & FieldBasedIndexPatternColumn & { operationType: \"sum\"; }" diff --git a/api_docs/observability.json b/api_docs/observability.json index a3d1bc950cb53..439fd18db6469 100644 --- a/api_docs/observability.json +++ b/api_docs/observability.json @@ -2351,7 +2351,7 @@ "description": [], "source": { "path": "x-pack/plugins/observability/server/plugin.ts", - "lineNumber": 22 + "lineNumber": 23 }, "signature": [ "LazyScopedAnnotationsClientFactory" @@ -2360,7 +2360,7 @@ ], "source": { "path": "x-pack/plugins/observability/server/plugin.ts", - "lineNumber": 21 + "lineNumber": 22 }, "lifecycle": "setup", "initialIsOpen": true diff --git a/dev_docs/assets/api_doc_pick.png b/dev_docs/assets/api_doc_pick.png new file mode 100644 index 0000000000000..825fa47b266cb Binary files /dev/null and b/dev_docs/assets/api_doc_pick.png differ diff --git a/dev_docs/assets/dev_docs_nested_object.png b/dev_docs/assets/dev_docs_nested_object.png new file mode 100644 index 0000000000000..a6b2f533b3858 Binary files /dev/null and b/dev_docs/assets/dev_docs_nested_object.png differ diff --git a/dev_docs/best_practices.mdx b/dev_docs/best_practices.mdx index 6156c05197289..4d51263f93372 100644 --- a/dev_docs/best_practices.mdx +++ b/dev_docs/best_practices.mdx @@ -12,6 +12,132 @@ tags: ['kibana', 'onboarding', 'dev', 'architecture'] First things first, be sure to review our and check out all the available platform that can simplify plugin development. +## Developer documentation + +### High-level documentation + +#### Structure + +Refer to [divio documentation](https://documentation.divio.com/) for guidance on where and how to structure our high-level documentation. + + and + sections are both _explanation_ oriented, + covers both _tutorials_ and _How to_, and +the section covers _reference_ material. + +#### Location + +If the information spans multiple plugins, consider adding it to the [dev_docs](https://github.com/elastic/kibana/tree/master/dev_docs) folder. If it is plugin specific, consider adding it inside the plugin folder. Write it in an mdx file if you would like it to show up in our new (beta) documentation system. + + + +To add docs into the new docs system, create an `.mdx` file that +contains . Read about the syntax . An extra step is needed to add a menu item. will walk you through how to set the docs system +up locally and edit the nav menu. + + + +#### Keep content fresh + +A fresh pair of eyes are invaluable. Recruit new hires to read, review and update documentation. Leads should also periodically review documentation to ensure it stays up to date. File issues any time you notice documentation is outdated. + +#### Consider your target audience + +Documentation in the Kibana Developer Guide is targeted towards developers building Kibana plugins. Keep implementation details about internal plugin code out of these docs. + +#### High to low level + +When a developer first lands in our docs, think about their journey. Introduce basic concepts before diving into details. The left navigation should be set up so documents on top are higher level than documents near the bottom. + +#### Think outside-in + +It's easy to forget what it felt like to first write code in Kibana, but do your best to frame these docs "outside-in". Don't use esoteric, internal language unless a definition is documented and linked. The fresh eyes of a new hire can be a great asset. + +### API documentation + +We automatically generate . The following guidelines will help ensure your are useful. + +#### Code comments + +Every publicly exposed function, class, interface, type, parameter and property should have a comment using JSDoc style comments. + +- Use `@param` tags for every function parameter. +- Use `@returns` tags for return types. +- Use `@throws` when appropriate. +- Use `@beta` or `@deprecated` when appropriate. +- Use `@internal` to indicate this API item is intended for internal use only, which will also remove it from the docs. + +#### Interfaces vs inlined types + +Prefer types and interfaces over complex inline objects. For example, prefer: + +```ts +/** +* The SearchSpec interface contains settings for creating a new SearchService, like +* username and password. +*/ +export interface SearchSpec { + /** + * Stores the username. Duh, + */ + username: string; + /** + * Stores the password. I hope it's encrypted! + */ + password: string; +} + + /** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: SearchSpec) => string; +``` + +over: + +```ts +/** + * Retrieve search services + * @param searchSpec Configuration information for initializing the search service. + * @returns the id of the search service + */ +export getSearchService: (searchSpec: { username: string; password: string }) => string; +``` + +In the former, there will be a link to the `SearchSpec` interface with documentation for the `username` and `password` properties. In the latter the object will render inline, without comments: + +![prefer interfaces documentation](./assets/dev_docs_nested_object.png) + +#### Export every type used in a public API + +When a publicly exported API items references a private type, this results in a broken link in our docs system. The private type is, by proxy, part of your public API, and as such, should be exported. + +Do: + +```ts +export interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +Don't: + +```ts +interface AnInterface { bar: string }; +export type foo: string | AnInterface; +``` + +#### Avoid “Pick” + +`Pick` not only ends up being unhelpful in our documentation system, but it's also of limited help in your IDE. For that reason, avoid `Pick` and other similarly complex types on your public API items. Using these semantics internally is fine. + +![pick api documentation](./assets/api_doc_pick.png) + +### Example plugins + +Running Kibana with `yarn start --run-examples` will include all [example plugins](https://github.com/elastic/kibana/tree/master/examples). These are tested examples of platform services in use. We strongly encourage anyone providing a platform level service or to include a tutorial that links to a tested example plugin. This is better than relying on copied code snippets, which can quickly get out of date. + ## Performance Build with scalability in mind. diff --git a/docs/api/actions-and-connectors.asciidoc b/docs/api/actions-and-connectors.asciidoc index 5480cdd57f691..ff4cb8401091e 100644 --- a/docs/api/actions-and-connectors.asciidoc +++ b/docs/api/actions-and-connectors.asciidoc @@ -5,19 +5,19 @@ Manage Actions and Connectors. The following connector APIs are available: -* <> to retrieve a single connector by ID +* <> to retrieve a single connector by ID -* <> to retrieve all connectors +* <> to retrieve all connectors -* <> to retrieve a list of all connector types +* <> to retrieve a list of all connector types -* <> to create connectors +* <> to create connectors -* <> to update the attributes for an existing connector +* <> to update the attributes for an existing connector -* <> to execute a connector by ID +* <> to execute a connector by ID -* <> to delete a connector by ID +* <> to delete a connector by ID For deprecated APIs, refer to <>. diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index c9a09e890ea6d..554e84615d568 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -1,25 +1,25 @@ -[[actions-and-connectors-api-create]] +[[create-connector-api]] === Create connector API ++++ -Create connector API +Create connector ++++ Creates a connector. -[[actions-and-connectors-api-create-request]] +[[create-connector-api-request]] ==== Request `POST :/api/actions/connector` `POST :/s//api/actions/connector` -[[actions-and-connectors-api-create-path-params]] +[[create-connector-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-create-request-body]] +[[create-connector-api-request-body]] ==== Request body `name`:: @@ -36,15 +36,15 @@ Creates a connector. (Required, object) The secrets configuration for the connector. Secrets configuration properties vary depending on the connector type. For information about the secrets configuration properties, refer to <>. + -WARNING: Remember these values. You must provide them each time you call the <> API. +WARNING: Remember these values. You must provide them each time you call the <> API. -[[actions-and-connectors-api-create-request-codes]] +[[create-connector-api-request-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-create-example]] +[[create-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/delete.asciidoc b/docs/api/actions-and-connectors/delete.asciidoc index a9f9e658613e0..021a3f7cdf3f7 100644 --- a/docs/api/actions-and-connectors/delete.asciidoc +++ b/docs/api/actions-and-connectors/delete.asciidoc @@ -1,21 +1,21 @@ -[[actions-and-connectors-api-delete]] +[[delete-connector-api]] === Delete connector API ++++ -Delete connector API +Delete connector ++++ Deletes an connector by ID. WARNING: When you delete a connector, _it cannot be recovered_. -[[actions-and-connectors-api-delete-request]] +[[delete-connector-api-request]] ==== Request `DELETE :/api/actions/connector/` `DELETE :/s//api/actions/connector/` -[[actions-and-connectors-api-delete-path-params]] +[[delete-connector-api-path-params]] ==== Path parameters `id`:: @@ -24,7 +24,7 @@ WARNING: When you delete a connector, _it cannot be recovered_. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-delete-response-codes]] +[[delete-connector-api-response-codes]] ==== Response code `200`:: diff --git a/docs/api/actions-and-connectors/execute.asciidoc b/docs/api/actions-and-connectors/execute.asciidoc index b87380907f7bb..e830c9b4bbf88 100644 --- a/docs/api/actions-and-connectors/execute.asciidoc +++ b/docs/api/actions-and-connectors/execute.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-execute]] +[[execute-connector-api]] === Execute connector API ++++ -Execute connector API +Execute connector ++++ Executes a connector by ID. -[[actions-and-connectors-api-execute-request]] +[[execute-connector-api-request]] ==== Request `POST :/api/actions/connector//_execute` `POST :/s//api/actions/connector//_execute` -[[actions-and-connectors-api-execute-params]] +[[execute-connector-api-params]] ==== Path parameters `id`:: @@ -22,20 +22,20 @@ Executes a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-execute-request-body]] +[[execute-connector-api-request-body]] ==== Request body `params`:: (Required, object) The parameters of the connector. Parameter properties vary depending on the connector type. For information about the parameter properties, refer to <>. -[[actions-and-connectors-api-execute-codes]] +[[execute-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-execute-example]] +[[execute-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index 33d37a4add4dd..0d9af45c4ef0c 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-get]] +[[get-connector-api]] === Get connector API ++++ -Get connector API +Get connector ++++ Retrieves a connector by ID. -[[actions-and-connectors-api-get-request]] +[[get-connector-api-request]] ==== Request `GET :/api/actions/connector/` `GET :/s//api/actions/connector/` -[[actions-and-connectors-api-get-params]] +[[get-connector-api-params]] ==== Path parameters `id`:: @@ -22,13 +22,13 @@ Retrieves a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-codes]] +[[get-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-example]] +[[get-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index 8b4977d61e741..e4e67a9bbde73 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-get-all]] -=== Get all actions API +[[get-all-connectors-api]] +=== Get all connectors API ++++ -Get all actions API +Get all connectors ++++ Retrieves all connectors. -[[actions-and-connectors-api-get-all-request]] +[[get-all-connectors-api-request]] ==== Request `GET :/api/actions/connectors` `GET :/s//api/actions/connectors` -[[actions-and-connectors-api-get-all-path-params]] +[[get-all-connectors-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-all-codes]] +[[get-all-connectors-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-all-example]] +[[get-all-connectors-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/legacy/create.asciidoc b/docs/api/actions-and-connectors/legacy/create.asciidoc index faf6227f01947..af4feddcb80fb 100644 --- a/docs/api/actions-and-connectors/legacy/create.asciidoc +++ b/docs/api/actions-and-connectors/legacy/create.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-create]] ==== Legacy Create connector API ++++ -Legacy Create connector API +Legacy Create connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Creates a connector. diff --git a/docs/api/actions-and-connectors/legacy/delete.asciidoc b/docs/api/actions-and-connectors/legacy/delete.asciidoc index b02f1011fd9b4..170fceba2d157 100644 --- a/docs/api/actions-and-connectors/legacy/delete.asciidoc +++ b/docs/api/actions-and-connectors/legacy/delete.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-delete]] ==== Legacy Delete connector API ++++ -Legacy Delete connector API +Legacy Delete connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Deletes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/execute.asciidoc b/docs/api/actions-and-connectors/legacy/execute.asciidoc index 30cb18c54aa69..200844ab72f17 100644 --- a/docs/api/actions-and-connectors/legacy/execute.asciidoc +++ b/docs/api/actions-and-connectors/legacy/execute.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-execute]] ==== Legacy Execute connector API ++++ -Legacy Execute connector API +Legacy Execute connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Executes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get.asciidoc b/docs/api/actions-and-connectors/legacy/get.asciidoc index cf8cc1b6b677e..1b138fb7032e0 100644 --- a/docs/api/actions-and-connectors/legacy/get.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get]] ==== Legacy Get connector API ++++ -Legacy Get connector API +Legacy Get connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get_all.asciidoc b/docs/api/actions-and-connectors/legacy/get_all.asciidoc index 24ad446d95d95..ba235955c005e 100644 --- a/docs/api/actions-and-connectors/legacy/get_all.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get_all.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get-all]] ==== Legacy Get all connector API ++++ -Legacy Get all connector API +Legacy Get all connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves all connectors. diff --git a/docs/api/actions-and-connectors/legacy/list.asciidoc b/docs/api/actions-and-connectors/legacy/list.asciidoc index 86026f332d917..8acfd5415af57 100644 --- a/docs/api/actions-and-connectors/legacy/list.asciidoc +++ b/docs/api/actions-and-connectors/legacy/list.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-list]] ==== Legacy List connector types API ++++ -Legacy List all connector types API +Legacy List all connector types ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a list of all connector types. diff --git a/docs/api/actions-and-connectors/legacy/update.asciidoc b/docs/api/actions-and-connectors/legacy/update.asciidoc index c2e841988717a..517daf9a40dca 100644 --- a/docs/api/actions-and-connectors/legacy/update.asciidoc +++ b/docs/api/actions-and-connectors/legacy/update.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-update]] ==== Legacy Update connector API ++++ -Legacy Update connector API +Legacy Update connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Updates the attributes for an existing connector. diff --git a/docs/api/actions-and-connectors/list.asciidoc b/docs/api/actions-and-connectors/list.asciidoc index 941f7b4376e91..bd1ccb777b9ae 100644 --- a/docs/api/actions-and-connectors/list.asciidoc +++ b/docs/api/actions-and-connectors/list.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-list]] +[[list-connector-types-api]] === List connector types API ++++ -List all connector types API +List all connector types ++++ Retrieves a list of all connector types. -[[actions-and-connectors-api-list-request]] +[[list-connector-types-api-request]] ==== Request `GET :/api/actions/connector_types` `GET :/s//api/actions/connector_types` -[[actions-and-connectors-api-list-path-params]] +[[list-connector-types-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-list-codes]] +[[list-connector-types-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-list-example]] +[[list-connector-types-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc index 6c4e6040bdfb5..f522cb8d048e0 100644 --- a/docs/api/actions-and-connectors/update.asciidoc +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-update]] +[[update-connector-api]] === Update connector API ++++ -Update connector API +Update connector ++++ Updates the attributes for an existing connector. -[[actions-and-connectors-api-update-request]] +[[update-connector-api-request]] ==== Request `PUT :/api/actions/connector/` `PUT :/s//api/actions/connector/` -[[actions-and-connectors-api-update-params]] +[[update-connector-api-params]] ==== Path parameters `id`:: @@ -22,7 +22,7 @@ Updates the attributes for an existing connector. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-update-request-body]] +[[update-connector-api-request-body]] ==== Request body `name`:: @@ -34,13 +34,13 @@ Updates the attributes for an existing connector. `secrets`:: (Required, object) The updated secrets configuration for the connector. Secrets properties vary depending on the connector type. For information about the secrets configuration properties, refer to <>. -[[actions-and-connectors-api-update-codes]] +[[update-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-update-example]] +[[update-connector-api-example]] ==== Example [source,sh] diff --git a/docs/developer/telemetry.asciidoc b/docs/developer/telemetry.asciidoc index fe2bf5f957379..c478c091c1c10 100644 --- a/docs/developer/telemetry.asciidoc +++ b/docs/developer/telemetry.asciidoc @@ -8,6 +8,7 @@ The operations we current report timing data for: * Total execution time of `yarn kbn bootstrap`. * Total execution time of `@kbn/optimizer` runs as well as the following metadata about the runs: The number of bundles created, the number of bundles which were cached, usage of `--watch`, `--dist`, `--workers` and `--no-cache` flags, and the count of themes being built. * The time from when you run `yarn start` until both the Kibana server and `@kbn/optimizer` are ready for use. +* The time it takes for the Kibana server to start listening after it is spawned by `yarn start`. Along with the execution time of each execution, we ship the following information about your machine to the service: diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index df5ce62cc07af..6ca7a83ac0a03 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -87,7 +87,9 @@ readonly links: { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index da3ae17171c81..3847ab0c6183a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 69cfb818561e5..7be45c6c173b4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -27,10 +27,10 @@ export interface SavedObjectsFindOptions | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | -| [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | +| [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) | estypes.Id[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | -| [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | +| [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | estypes.SortOrder | | | [type](./kibana-plugin-core-public.savedobjectsfindoptions.type.md) | string | string[] | | | [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md index 99ca2c34e77be..7016e1f1b72de 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md @@ -9,5 +9,5 @@ Use the sort values from the previous page to retrieve the next page of results. Signature: ```typescript -searchAfter?: unknown[]; +searchAfter?: estypes.Id[]; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md index 3834c802fa184..36f99e51ea8c6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md @@ -7,5 +7,5 @@ Signature: ```typescript -sortOrder?: string; +sortOrder?: estypes.SortOrder; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.close.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.close.md new file mode 100644 index 0000000000000..f7cfab446eeca --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.close.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) > [close](./kibana-plugin-core-server.isavedobjectspointintimefinder.close.md) + +## ISavedObjectsPointInTimeFinder.close property + +Closes the Point-In-Time associated with this finder instance. + +Once you have retrieved all of the results you need, it is recommended to call `close()` to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to `find` fails for any reason. + +Signature: + +```typescript +close: () => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md new file mode 100644 index 0000000000000..1755ff40c2bc0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) > [find](./kibana-plugin-core-server.isavedobjectspointintimefinder.find.md) + +## ISavedObjectsPointInTimeFinder.find property + +An async generator which wraps calls to `savedObjectsClient.find` and iterates over multiple pages of results using `_pit` and `search_after`. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated `perPage` size. + +Signature: + +```typescript +find: () => AsyncGenerator; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md new file mode 100644 index 0000000000000..4686df18e0134 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) + +## ISavedObjectsPointInTimeFinder interface + + +Signature: + +```typescript +export interface ISavedObjectsPointInTimeFinder +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [close](./kibana-plugin-core-server.isavedobjectspointintimefinder.close.md) | () => Promise<void> | Closes the Point-In-Time associated with this finder instance.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | +| [find](./kibana-plugin-core-server.isavedobjectspointintimefinder.find.md) | () => AsyncGenerator<SavedObjectsFindResponse> | An async generator which wraps calls to savedObjectsClient.find and iterates over multiple pages of results using _pit and search_after. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated perPage size. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 8dd4667002ead..4bf00d2da6e23 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -98,6 +98,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IndexSettingsDeprecationInfo](./kibana-plugin-core-server.indexsettingsdeprecationinfo.md) | | | [IRenderOptions](./kibana-plugin-core-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-core-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-core-server.routeconfig.md) and [RequestHandler](./kibana-plugin-core-server.requesthandler.md) for more information about arguments to route registrations. | +| [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) | | | [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) | Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional asCurrentUser method that doesn't use credentials of the Kibana internal user (as asInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | | [IUiSettingsClient](./kibana-plugin-core-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) | Request events. | @@ -158,6 +159,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | +| [SavedObjectsCreatePointInTimeFinderDependencies](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md) | | | [SavedObjectsDeleteByNamespaceOptions](./kibana-plugin-core-server.savedobjectsdeletebynamespaceoptions.md) | | | [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) | | | [SavedObjectsDeleteFromNamespacesResponse](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md) | | @@ -305,6 +307,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) | | +| [SavedObjectsCreatePointInTimeFinderOptions](./kibana-plugin-core-server.savedobjectscreatepointintimefinderoptions.md) | | | [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md) | Transformation function used to mutate the exported objects of the associated type.A type's export transform function will be executed once per user-initiated export, for all objects of that type. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md index dc765260a08ca..79c7d18adf306 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md @@ -6,6 +6,8 @@ Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). +Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md new file mode 100644 index 0000000000000..8afd963464574 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md @@ -0,0 +1,53 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [createPointInTimeFinder](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) + +## SavedObjectsClient.createPointInTimeFinder() method + +Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any `find` queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client. + +Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments. + +The generator wraps calls to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) and iterates over multiple pages of results using `_pit` and `search_after`. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated `perPage`. + +Once you have retrieved all of the results you need, it is recommended to call `close()` to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to `find` fails for any reason. + +Signature: + +```typescript +createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| findOptions | SavedObjectsCreatePointInTimeFinderOptions | | +| dependencies | SavedObjectsCreatePointInTimeFinderDependencies | | + +Returns: + +`ISavedObjectsPointInTimeFinder` + +## Example + + +```ts +const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + type: 'visualization', + search: 'foo*', + perPage: 100, +}; + +const finder = savedObjectsClient.createPointInTimeFinder(findOptions); + +const responses: SavedObjectFindResponse[] = []; +for await (const response of finder.find()) { + responses.push(...response); + if (doneSearching) { + await finder.close(); + } +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 887f7f7d93a87..95c2251f72c90 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -30,13 +30,14 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | -| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md).Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | +| [createPointInTimeFinder(findOptions, dependencies)](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) | | Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any find queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client.Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments.The generator wraps calls to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) and iterates over multiple pages of results using _pit and search_after. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated perPage.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | -| [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. | +| [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT.Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md index 56c1d6d1ddc33..c76159ffa5032 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md @@ -6,6 +6,8 @@ Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. +Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.client.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.client.md new file mode 100644 index 0000000000000..95ab9e225c049 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.client.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreatePointInTimeFinderDependencies](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md) > [client](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.client.md) + +## SavedObjectsCreatePointInTimeFinderDependencies.client property + +Signature: + +```typescript +client: Pick; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md new file mode 100644 index 0000000000000..47c640bfabcb0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreatePointInTimeFinderDependencies](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md) + +## SavedObjectsCreatePointInTimeFinderDependencies interface + + +Signature: + +```typescript +export interface SavedObjectsCreatePointInTimeFinderDependencies +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [client](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.client.md) | Pick<SavedObjectsClientContract, 'find' | 'openPointInTimeForType' | 'closePointInTime'> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderoptions.md new file mode 100644 index 0000000000000..928c6f72bcbf5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreatepointintimefinderoptions.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreatePointInTimeFinderOptions](./kibana-plugin-core-server.savedobjectscreatepointintimefinderoptions.md) + +## SavedObjectsCreatePointInTimeFinderOptions type + + +Signature: + +```typescript +export declare type SavedObjectsCreatePointInTimeFinderOptions = Omit; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 6f7c05ea469bc..a92b1f48d08eb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -27,10 +27,10 @@ export interface SavedObjectsFindOptions | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | -| [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | +| [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) | estypes.Id[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | -| [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | +| [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | estypes.SortOrder | | | [type](./kibana-plugin-core-server.savedobjectsfindoptions.type.md) | string | string[] | | | [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md index 6364370948976..9afd602259a78 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md @@ -9,5 +9,5 @@ Use the sort values from the previous page to retrieve the next page of results. Signature: ```typescript -searchAfter?: unknown[]; +searchAfter?: estypes.Id[]; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md index d247b9e38e448..e1c657e3a5171 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md @@ -7,5 +7,5 @@ Signature: ```typescript -sortOrder?: string; +sortOrder?: estypes.SortOrder; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md index 0f8e9c59236bb..a729ce32e1c80 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md @@ -16,5 +16,5 @@ export interface SavedObjectsFindResult extends SavedObject | Property | Type | Description | | --- | --- | --- | | [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) | number | The Elasticsearch _score of this result. | -| [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) | unknown[] | The Elasticsearch sort value of this result. | +| [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) | string[] | The Elasticsearch sort value of this result. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md index 17f5268724332..e73d6b4926d89 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md @@ -9,7 +9,7 @@ The Elasticsearch `sort` value of this result. Signature: ```typescript -sort?: unknown[]; +sort?: string[]; ``` ## Remarks diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md index 68e9bb09456cd..8da2458cf007e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions +export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions ``` ## Properties @@ -18,4 +18,5 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt | [initialize](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.initialize.md) | boolean | (default=false) If true, sets all the counter fields to 0 if they don't already exist. Existing fields will be left as-is and won't be incremented. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.migrationversion.md) | SavedObjectsMigrationVersion | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | | [refresh](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.refresh.md) | MutatingOperationRefreshSetting | (default='wait\_for') The Elasticsearch refresh setting for this operation. See [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | +| [upsertAttributes](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md) | Attributes | Attributes to use when upserting the document if it doesn't exist. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md new file mode 100644 index 0000000000000..d5657dd65771f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) > [upsertAttributes](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.upsertattributes.md) + +## SavedObjectsIncrementCounterOptions.upsertAttributes property + +Attributes to use when upserting the document if it doesn't exist. + +Signature: + +```typescript +upsertAttributes?: Attributes; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md index 8f9dca35fa362..b9d81c89bffd7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md @@ -6,6 +6,8 @@ Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using `openPointInTimeForType`. +Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md new file mode 100644 index 0000000000000..5d9d2857f6e0b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md @@ -0,0 +1,53 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [createPointInTimeFinder](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) + +## SavedObjectsRepository.createPointInTimeFinder() method + +Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any `find` queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client. + +Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments. + +This generator wraps calls to [SavedObjectsRepository.find()](./kibana-plugin-core-server.savedobjectsrepository.find.md) and iterates over multiple pages of results using `_pit` and `search_after`. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated `perPage`. + +Once you have retrieved all of the results you need, it is recommended to call `close()` to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to `find` fails for any reason. + +Signature: + +```typescript +createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| findOptions | SavedObjectsCreatePointInTimeFinderOptions | | +| dependencies | SavedObjectsCreatePointInTimeFinderDependencies | | + +Returns: + +`ISavedObjectsPointInTimeFinder` + +## Example + + +```ts +const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + type: 'visualization', + search: 'foo*', + perPage: 100, +}; + +const finder = savedObjectsClient.createPointInTimeFinder(findOptions); + +const responses: SavedObjectFindResponse[] = []; +for await (const response of finder.find()) { + responses.push(...response); + if (doneSearching) { + await finder.close(); + } +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md index eb18e064c84e2..59d98bf4d607b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md @@ -9,7 +9,7 @@ Increments all the specified counter fields (by one by default). Creates the doc Signature: ```typescript -incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; +incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; ``` ## Parameters @@ -19,7 +19,7 @@ incrementCounter(type: string, id: string, counterFields: Arraystring | The type of saved object whose fields should be incremented | | id | string | The id of the document whose fields should be incremented | | counterFields | Array<string | SavedObjectsIncrementCounterField> | An array of field names to increment or an array of [SavedObjectsIncrementCounterField](./kibana-plugin-core-server.savedobjectsincrementcounterfield.md) | -| options | SavedObjectsIncrementCounterOptions | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | +| options | SavedObjectsIncrementCounterOptions<T> | [SavedObjectsIncrementCounterOptions](./kibana-plugin-core-server.savedobjectsincrementcounteroptions.md) | Returns: @@ -52,5 +52,19 @@ repository 'stats.apiCalls', ]) +// Increment the apiCalls field counter by 4 +repository + .incrementCounter('dashboard_counter_type', 'counter_id', [ + { fieldName: 'stats.apiCalls' incrementBy: 4 }, + ]) + +// Initialize the document with arbitrary fields if not present +repository.incrementCounter<{ appId: string }>( + 'dashboard_counter_type', + 'counter_id', + [ 'stats.apiCalls'], + { upsertAttributes: { appId: 'myId' } } +) + ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 632d9c279cb88..00e6ed3aeddfc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -20,15 +20,16 @@ export declare class SavedObjectsRepository | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | -| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType.Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | +| [createPointInTimeFinder(findOptions, dependencies)](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) | | Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any find queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client.Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments.This generator wraps calls to [SavedObjectsRepository.find()](./kibana-plugin-core-server.savedobjectsrepository.find.md) and iterates over multiple pages of results using _pit and search_after. This will open a new Point-In-Time (PIT), and continue paging until a set of results is received that's smaller than the designated perPage.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | -| [openPointInTimeForType(type, { keepAlive, preference })](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to SavedObjects.find to search against that PIT. | +| [openPointInTimeForType(type, { keepAlive, preference })](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to SavedObjects.find to search against that PIT.Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md index 6b66882484520..b33765bb79dd8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md @@ -6,6 +6,8 @@ Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. +Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. + Signature: ```typescript diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilteredmetric.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilteredmetric.md new file mode 100644 index 0000000000000..71e3e025b931d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilteredmetric.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggFunctionsMapping](./kibana-plugin-plugins-data-public.aggfunctionsmapping.md) > [aggFilteredMetric](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilteredmetric.md) + +## AggFunctionsMapping.aggFilteredMetric property + +Signature: + +```typescript +aggFilteredMetric: ReturnType; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.md index b175b8d473d34..05388e2b86d7b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggfunctionsmapping.md @@ -28,6 +28,7 @@ export interface AggFunctionsMapping | [aggDateRange](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggdaterange.md) | ReturnType<typeof aggDateRange> | | | [aggDerivative](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggderivative.md) | ReturnType<typeof aggDerivative> | | | [aggFilter](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilter.md) | ReturnType<typeof aggFilter> | | +| [aggFilteredMetric](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilteredmetric.md) | ReturnType<typeof aggFilteredMetric> | | | [aggFilters](./kibana-plugin-plugins-data-public.aggfunctionsmapping.aggfilters.md) | ReturnType<typeof aggFilters> | | | [aggGeoBounds](./kibana-plugin-plugins-data-public.aggfunctionsmapping.agggeobounds.md) | ReturnType<typeof aggGeoBounds> | | | [aggGeoCentroid](./kibana-plugin-plugins-data-public.aggfunctionsmapping.agggeocentroid.md) | ReturnType<typeof aggGeoCentroid> | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchresponse.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchresponse.md index c8a372edbdb85..073b1d462986c 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchresponse.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iessearchresponse.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type IEsSearchResponse = IKibanaSearchResponse>; +export declare type IEsSearchResponse = IKibanaSearchResponse>; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.metric_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.metric_types.md index 637717692a38c..3b5cecf1a0b82 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.metric_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.metric_types.md @@ -20,6 +20,7 @@ export declare enum METRIC_TYPES | COUNT | "count" | | | CUMULATIVE\_SUM | "cumulative_sum" | | | DERIVATIVE | "derivative" | | +| FILTERED\_METRIC | "filtered_metric" | | | GEO\_BOUNDS | "geo_bounds" | | | GEO\_CENTROID | "geo_centroid" | | | MAX | "max" | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index e96fe8b8e08dc..623d6366d4d13 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -14,7 +14,7 @@ Fetch this source and reject the returned Promise on error Signature: ```typescript -fetch(options?: ISearchOptions): Promise>; +fetch(options?: ISearchOptions): Promise>; ``` ## Parameters @@ -25,5 +25,5 @@ fetch(options?: ISearchOptions): PromiseReturns: -`Promise>` +`Promise>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index bcf220a9a27e6..d5641107a88aa 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): import("rxjs").Observable>; +fetch$(options?: ISearchOptions): import("rxjs").Observable>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): import("rxjs").ObservableReturns: -`import("rxjs").Observable>` +`import("rxjs").Observable>` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilteredmetric.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilteredmetric.md new file mode 100644 index 0000000000000..9885a0afa40c6 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilteredmetric.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [AggFunctionsMapping](./kibana-plugin-plugins-data-server.aggfunctionsmapping.md) > [aggFilteredMetric](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilteredmetric.md) + +## AggFunctionsMapping.aggFilteredMetric property + +Signature: + +```typescript +aggFilteredMetric: ReturnType; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.md index f059bb2914847..86bf797572b09 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.aggfunctionsmapping.md @@ -28,6 +28,7 @@ export interface AggFunctionsMapping | [aggDateRange](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggdaterange.md) | ReturnType<typeof aggDateRange> | | | [aggDerivative](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggderivative.md) | ReturnType<typeof aggDerivative> | | | [aggFilter](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilter.md) | ReturnType<typeof aggFilter> | | +| [aggFilteredMetric](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilteredmetric.md) | ReturnType<typeof aggFilteredMetric> | | | [aggFilters](./kibana-plugin-plugins-data-server.aggfunctionsmapping.aggfilters.md) | ReturnType<typeof aggFilters> | | | [aggGeoBounds](./kibana-plugin-plugins-data-server.aggfunctionsmapping.agggeobounds.md) | ReturnType<typeof aggGeoBounds> | | | [aggGeoCentroid](./kibana-plugin-plugins-data-server.aggfunctionsmapping.agggeocentroid.md) | ReturnType<typeof aggGeoCentroid> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iessearchresponse.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iessearchresponse.md index d333af1b278c2..be208c0a51c81 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iessearchresponse.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iessearchresponse.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type IEsSearchResponse = IKibanaSearchResponse>; +export declare type IEsSearchResponse = IKibanaSearchResponse>; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md index 698b4bc7f2043..d408f00e33c9e 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.md @@ -14,6 +14,6 @@ export declare class IndexPatternsServiceProvider implements PluginSignature: ```typescript -setup(core: CoreSetup, { logger, expressions }: IndexPatternsServiceSetupDeps): void; +setup(core: CoreSetup, { expressions }: IndexPatternsServiceSetupDeps): void; ``` ## Parameters @@ -15,7 +15,7 @@ setup(core: CoreSetup, { logger, e | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataPluginStartDependencies, DataPluginStart> | | -| { logger, expressions } | IndexPatternsServiceSetupDeps | | +| { expressions } | IndexPatternsServiceSetupDeps | | Returns: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index 16d9ce457603e..e0734bc017f4f 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -109,5 +109,6 @@ | [KibanaContext](./kibana-plugin-plugins-data-server.kibanacontext.md) | | | [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [Query](./kibana-plugin-plugins-data-server.query.md) | | +| [SearchRequestHandlerContext](./kibana-plugin-plugins-data-server.searchrequesthandlercontext.md) | | | [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.metric_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.metric_types.md index 49df98b6d70a1..250173d11a056 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.metric_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.metric_types.md @@ -20,6 +20,7 @@ export declare enum METRIC_TYPES | COUNT | "count" | | | CUMULATIVE\_SUM | "cumulative_sum" | | | DERIVATIVE | "derivative" | | +| FILTERED\_METRIC | "filtered_metric" | | | GEO\_BOUNDS | "geo_bounds" | | | GEO\_CENTROID | "geo_centroid" | | | MAX | "max" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index f479ffd52e9b8..025cab9f48c1a 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchrequesthandlercontext.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchrequesthandlercontext.md new file mode 100644 index 0000000000000..f031ddfbd09af --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchrequesthandlercontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [SearchRequestHandlerContext](./kibana-plugin-plugins-data-server.searchrequesthandlercontext.md) + +## SearchRequestHandlerContext type + +Signature: + +```typescript +export declare type SearchRequestHandlerContext = IScopedSearchClient; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.getsavedobject.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.getsavedobject.md deleted file mode 100644 index dffce4a091718..0000000000000 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.getsavedobject.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [ExecutionContext](./kibana-plugin-plugins-expressions-public.executioncontext.md) > [getSavedObject](./kibana-plugin-plugins-expressions-public.executioncontext.getsavedobject.md) - -## ExecutionContext.getSavedObject property - -Allows to fetch saved objects from ElasticSearch. In browser `getSavedObject` function is provided automatically by the Expressions plugin. On the server the caller of the expression has to provide this context function. The reason is because on the browser we always know the user who tries to fetch a saved object, thus saved object client is scoped automatically to that user. However, on the server we can scope that saved object client to any user, or even not scope it at all and execute it as an "internal" user. - -Signature: - -```typescript -getSavedObject?: (type: string, id: string) => Promise>; -``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.md index 901b46f0888d4..1388e04c315e2 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executioncontext.md @@ -18,7 +18,6 @@ export interface ExecutionContextAbortSignal | Adds ability to abort current execution. | | [getKibanaRequest](./kibana-plugin-plugins-expressions-public.executioncontext.getkibanarequest.md) | () => KibanaRequest | Getter to retrieve the KibanaRequest object inside an expression function. Useful for functions which are running on the server and need to perform operations that are scoped to a specific user. | -| [getSavedObject](./kibana-plugin-plugins-expressions-public.executioncontext.getsavedobject.md) | <T extends SavedObjectAttributes = SavedObjectAttributes>(type: string, id: string) => Promise<SavedObject<T>> | Allows to fetch saved objects from ElasticSearch. In browser getSavedObject function is provided automatically by the Expressions plugin. On the server the caller of the expression has to provide this context function. The reason is because on the browser we always know the user who tries to fetch a saved object, thus saved object client is scoped automatically to that user. However, on the server we can scope that saved object client to any user, or even not scope it at all and execute it as an "internal" user. | | [getSearchContext](./kibana-plugin-plugins-expressions-public.executioncontext.getsearchcontext.md) | () => ExecutionContextSearch | Get search context of the expression. | | [getSearchSessionId](./kibana-plugin-plugins-expressions-public.executioncontext.getsearchsessionid.md) | () => string | undefined | Search context in which expression should operate. | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.executioncontext.inspectoradapters.md) | InspectorAdapters | Adapters for inspector plugin. | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.getsavedobject.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.getsavedobject.md deleted file mode 100644 index b8c8f4f3bb067..0000000000000 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.getsavedobject.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [ExecutionContext](./kibana-plugin-plugins-expressions-server.executioncontext.md) > [getSavedObject](./kibana-plugin-plugins-expressions-server.executioncontext.getsavedobject.md) - -## ExecutionContext.getSavedObject property - -Allows to fetch saved objects from ElasticSearch. In browser `getSavedObject` function is provided automatically by the Expressions plugin. On the server the caller of the expression has to provide this context function. The reason is because on the browser we always know the user who tries to fetch a saved object, thus saved object client is scoped automatically to that user. However, on the server we can scope that saved object client to any user, or even not scope it at all and execute it as an "internal" user. - -Signature: - -```typescript -getSavedObject?: (type: string, id: string) => Promise>; -``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.md index 39018599a2c92..8503f81ad7d25 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executioncontext.md @@ -18,7 +18,6 @@ export interface ExecutionContextAbortSignal | Adds ability to abort current execution. | | [getKibanaRequest](./kibana-plugin-plugins-expressions-server.executioncontext.getkibanarequest.md) | () => KibanaRequest | Getter to retrieve the KibanaRequest object inside an expression function. Useful for functions which are running on the server and need to perform operations that are scoped to a specific user. | -| [getSavedObject](./kibana-plugin-plugins-expressions-server.executioncontext.getsavedobject.md) | <T extends SavedObjectAttributes = SavedObjectAttributes>(type: string, id: string) => Promise<SavedObject<T>> | Allows to fetch saved objects from ElasticSearch. In browser getSavedObject function is provided automatically by the Expressions plugin. On the server the caller of the expression has to provide this context function. The reason is because on the browser we always know the user who tries to fetch a saved object, thus saved object client is scoped automatically to that user. However, on the server we can scope that saved object client to any user, or even not scope it at all and execute it as an "internal" user. | | [getSearchContext](./kibana-plugin-plugins-expressions-server.executioncontext.getsearchcontext.md) | () => ExecutionContextSearch | Get search context of the expression. | | [getSearchSessionId](./kibana-plugin-plugins-expressions-server.executioncontext.getsearchsessionid.md) | () => string | undefined | Search context in which expression should operate. | | [inspectorAdapters](./kibana-plugin-plugins-expressions-server.executioncontext.inspectoradapters.md) | InspectorAdapters | Adapters for inspector plugin. | diff --git a/docs/maps/images/gs_add_cloropeth_layer.png b/docs/maps/images/gs_add_cloropeth_layer.png index 1528f404026f2..42e00ccc5dd24 100644 Binary files a/docs/maps/images/gs_add_cloropeth_layer.png and b/docs/maps/images/gs_add_cloropeth_layer.png differ diff --git a/docs/maps/images/gs_add_es_document_layer.png b/docs/maps/images/gs_add_es_document_layer.png index f4ffbc581745d..d7616c4b11fe0 100644 Binary files a/docs/maps/images/gs_add_es_document_layer.png and b/docs/maps/images/gs_add_es_document_layer.png differ diff --git a/docs/maps/images/sample_data_web_logs.png b/docs/maps/images/sample_data_web_logs.png index 3b0c2ba3f12c0..f4f4de88f1992 100644 Binary files a/docs/maps/images/sample_data_web_logs.png and b/docs/maps/images/sample_data_web_logs.png differ diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index c62aafac00d3f..39ea4daf2ba33 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -67,8 +67,9 @@ and lighter shades will symbolize countries with less traffic. . In **Layer style**, set: -** **Fill color** to the grey color ramp +** **Fill color: As number** to the grey color ramp ** **Border color** to white +** **Label** to symbol label . Click **Save & close**. + @@ -102,7 +103,7 @@ The layer is only visible when users zoom in. . In **Layer settings**, set: ** **Name** to `Actual Requests` -** **Visibilty** to the range [9, 24] +** **Visibility** to the range [9, 24] ** **Opacity** to 100% . Add a tooltip field and select **agent**, **bytes**, **clientip**, **host**, @@ -134,9 +135,9 @@ grids with less bytes transferred. ** **Name** to `Total Requests and Bytes` ** **Visibility** to the range [0, 9] ** **Opacity** to 100% -. Add a metric with: -** **Aggregation** set to **Sum** -** **Field** set to **bytes** +. In **Metrics**, use: +** **Agregation** set to **Count**, and +** **Aggregation** set to **Sum** with **Field** set to **bytes** . In **Layer style**, change **Symbol size**: ** Set the field select to *sum bytes*. ** Set the min size to 7 and the max size to 25 px. diff --git a/docs/user/production-considerations/alerting-production-considerations.asciidoc b/docs/user/production-considerations/alerting-production-considerations.asciidoc index 57c255c809dc5..6294a4fe6f14a 100644 --- a/docs/user/production-considerations/alerting-production-considerations.asciidoc +++ b/docs/user/production-considerations/alerting-production-considerations.asciidoc @@ -49,3 +49,16 @@ It is difficult to predict how much throughput is needed to ensure all rules and By counting rules as recurring tasks and actions as non-recurring tasks, a rough throughput <> as a _tasks per minute_ measurement. Predicting the buffer required to account for actions depends heavily on the rule types you use, the amount of alerts they might detect, and the number of actions you might choose to assign to action groups. With that in mind, regularly <> of your Task Manager instances. + +[float] +[[event-log-ilm]] +=== Event log index lifecycle managment + +Alerts and actions log activity in a set of "event log" indices. These indices are configured with an index lifecycle management (ILM) policy, which you can customize. The default policy rolls over the index when it reaches 50GB, or after 30 days. Indices over 90 days old are deleted. + +The name of the index policy is `kibana-event-log-policy`. {kib} creates the index policy on startup, if it doesn't already exist. The index policy can be customized for your environment, but {kib} never modifies the index policy after creating it. + +Because Kibana uses the documents to display historic data, you should set the delete phase longer than you would like the historic data to be shown. For example, if you would like to see one month's worth of historic data, you should set the delete phase to at least one month. + +For more information on index lifecycle management, see: +{ref}/index-lifecycle-management.html[Index Lifecycle Policies]. diff --git a/docs/user/production-considerations/production.asciidoc b/docs/user/production-considerations/production.asciidoc index 8802719f95a95..726747d5d69d0 100644 --- a/docs/user/production-considerations/production.asciidoc +++ b/docs/user/production-considerations/production.asciidoc @@ -10,6 +10,7 @@ * <> * <> * <> +* <> * <> * <> @@ -56,6 +57,7 @@ protections. To do this, set `csp.strict` to `true` in your `kibana.yml`: +[source,js] -------- csp.strict: true -------- @@ -82,6 +84,7 @@ To use a local client node to load balance Kibana requests: . Install Elasticsearch on the same machine as Kibana. . Configure the node as a Coordinating only node. In `elasticsearch.yml`, set `node.data`, `node.master` and `node.ingest` to `false`: + +[source,js] -------- # 3. You want this node to be neither master nor data node nor ingest node, but # to act as a "search load balancer" (fetching data from nodes, @@ -94,11 +97,13 @@ node.ingest: false . Configure the client node to join your Elasticsearch cluster. In `elasticsearch.yml`, set the `cluster.name` to the name of your cluster. + +[source,js] -------- cluster.name: "my_cluster" -------- . Check your transport and HTTP host configs in `elasticsearch.yml` under `network.host` and `transport.host`. The `transport.host` needs to be on the network reachable to the cluster members, the `network.host` is the network for the HTTP connection for Kibana (localhost:9200 by default). + +[source,js] -------- network.host: localhost http.port: 9200 @@ -110,6 +115,7 @@ transport.tcp.port: 9300 - 9400 . Make sure Kibana is configured to point to your local client node. In `kibana.yml`, the `elasticsearch.hosts` setting should be set to `["localhost:9200"]`. + +[source,js] -------- # The Elasticsearch instance to use for all your queries. elasticsearch.hosts: ["http://localhost:9200"] @@ -121,12 +127,14 @@ elasticsearch.hosts: ["http://localhost:9200"] To serve multiple Kibana installations behind a load balancer, you must change the configuration. See {kibana-ref}/settings.html[Configuring Kibana] for details on each setting. Settings unique across each Kibana instance: +[source,js] -------- server.uuid server.name -------- Settings unique across each host (for example, running multiple installations on the same virtual machine): +[source,js] -------- logging.dest path.data @@ -135,6 +143,7 @@ server.port -------- Settings that must be the same: +[source,js] -------- xpack.security.encryptionKey //decrypting session information xpack.reporting.encryptionKey //decrypting reports @@ -143,11 +152,24 @@ xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys // saved objects encr -------- Separate configuration files can be used from the command line by using the `-c` flag: +[source,js] -------- bin/kibana -c config/instance1.yml bin/kibana -c config/instance2.yml -------- +[float] +[[accessing-load-balanced-kibana]] +=== Accessing multiple load-balanced {kib} clusters + +To access multiple load-balanced {kib} clusters from the same browser, +set `xpack.security.cookieName` in the configuration. +This avoids conflicts between cookies from the different {kib} instances. + +In each cluster, {kib} instances should have the same `cookieName` +value. This will achieve seamless high availability and keep the session +active in case of failure from the currently used instance. + [float] [[high-availability]] === High availability across multiple {es} nodes @@ -157,6 +179,7 @@ Kibana will transparently connect to an available node and continue operating. Currently the Console application is limited to connecting to the first node listed. In kibana.yml: +[source,js] -------- elasticsearch.hosts: - http://elasticsearch1:9200 @@ -175,6 +198,7 @@ it may make sense to tweak limits to meet more specific requirements. You can modify this limit by setting `--max-old-space-size` in the `node.options` config file that can be found inside `kibana/config` folder or any other configured with the environment variable `KBN_PATH_CONF` (for example in debian based system would be `/etc/kibana`). The option accepts a limit in MB: +[source,js] -------- --max-old-space-size=2048 -------- diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index c96b294c0c50d..5e75aef0d9570 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -706,3 +706,21 @@ These rough calculations give you a lower bound to the required throughput, whic Given these inferred attributes, it would be safe to assume that a single {kib} instance with default settings **would not** provide the required throughput. It is possible that scaling horizontally by adding a couple more {kib} instances will. For details on scaling Task Manager, see <>. + +[float] +[[task-manager-cannot-operate-when-inline-scripts-are-disabled]] +==== Inline scripts are disabled in {es} + +*Problem*: + +Tasks are not running, and the server logs contain the following error message: + +[source, txt] +-------------------------------------------------- +[warning][plugins][taskManager] Task Manager cannot operate when inline scripts are disabled in {es} +-------------------------------------------------- + +*Solution*: + +Inline scripts are a hard requirement for Task Manager to function. +To enable inline scripting, see the Elasticsearch documentation for {ref}/modules-scripting-security.html#allowed-script-types-setting[configuring allowed script types setting]. diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 8822be035a3d1..c87bf21e0e71c 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -145,7 +145,8 @@ export const SearchExamplesApp = ({ setResponse(res.rawResponse); setTimeTook(res.rawResponse.took); const avgResult: number | undefined = res.rawResponse.aggregations - ? res.rawResponse.aggregations[1].value + ? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response + res.rawResponse.aggregations[1].value : undefined; const message = ( diff --git a/examples/search_examples/public/search_sessions/app.tsx b/examples/search_examples/public/search_sessions/app.tsx index bf57964dc1f86..a768600db24ee 100644 --- a/examples/search_examples/public/search_sessions/app.tsx +++ b/examples/search_examples/public/search_sessions/app.tsx @@ -702,13 +702,15 @@ function doSearch( const startTs = performance.now(); // Submit the search request using the `data.search` service. + // @ts-expect-error request.params is incompatible. Filter is not assignable to QueryContainer return data.search .search(req, { sessionId }) .pipe( tap((res) => { if (isCompleteResponse(res)) { const avgResult: number | undefined = res.rawResponse.aggregations - ? res.rawResponse.aggregations[1]?.value ?? res.rawResponse.aggregations[2]?.value + ? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response + res.rawResponse.aggregations[1]?.value ?? res.rawResponse.aggregations[2]?.value : undefined; const message = ( diff --git a/package.json b/package.json index 32cf8dc1aee0f..32def9e2e9fc6 100644 --- a/package.json +++ b/package.json @@ -95,18 +95,23 @@ "yarn": "^1.21.1" }, "dependencies": { + "@elastic/apm-rum": "^5.6.1", + "@elastic/apm-rum-react": "^1.2.5", + "@elastic/charts": "26.0.0", "@elastic/datemath": "link:packages/elastic-datemath", - "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.3", + "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", "@elastic/eui": "31.7.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", + "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", "@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", + "@elastic/ui-ace": "0.2.3", "@hapi/boom": "^9.1.1", "@hapi/cookie": "^11.0.2", "@hapi/good-squeeze": "6.0.0", @@ -131,9 +136,15 @@ "@kbn/tinymath": "link: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:packages/kbn-utility-types", "@kbn/utils": "link:packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", + "@mapbox/geojson-rewind": "^0.5.0", + "@mapbox/mapbox-gl-draw": "^1.2.0", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@mapbox/vector-tile": "1.3.1", + "@scant/router": "^0.1.1", "@slack/webhook": "^5.0.4", "@turf/along": "6.0.1", "@turf/area": "6.0.1", @@ -151,41 +162,59 @@ "accept": "3.0.2", "ajv": "^6.12.4", "angular": "^1.8.0", + "angular-aria": "^1.8.0", "angular-elastic": "^2.5.1", + "angular-recursion": "^1.0.5", "angular-resource": "1.8.0", + "angular-route": "^1.8.0", "angular-sanitize": "^1.8.0", + "angular-sortable-view": "^0.0.17", "angular-ui-ace": "0.2.3", "antlr4ts": "^0.5.0-alpha.3", "apollo-cache-inmemory": "1.6.2", "apollo-client": "^2.3.8", + "apollo-link": "^1.2.3", + "apollo-link-error": "^1.1.7", "apollo-link-http": "^1.5.16", "apollo-link-http-common": "^0.2.15", "apollo-link-schema": "^1.1.0", + "apollo-link-state": "^0.4.1", "apollo-server-core": "^1.3.6", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", "archiver": "^5.2.0", "axios": "^0.21.1", + "base64-js": "^1.3.1", "bluebird": "3.5.5", "brace": "0.11.1", + "broadcast-channel": "^3.0.3", "chalk": "^4.1.0", "check-disk-space": "^2.1.0", + "cheerio": "0.22.0", "chokidar": "^3.4.3", "chroma-js": "^1.4.1", "classnames": "2.2.6", "color": "1.0.3", "commander": "^3.0.2", + "compare-versions": "3.5.1", "concat-stream": "1.6.2", + "constate": "^1.3.2", + "cronstrue": "^1.51.0", "content-disposition": "0.5.3", + "copy-to-clipboard": "^3.0.8", "core-js": "^3.6.5", "custom-event-polyfill": "^0.3.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", + "d3": "3.5.17", "d3-array": "1.2.4", + "d3-cloud": "1.2.5", + "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", "dedent": "^0.7.0", "deep-freeze-strict": "^1.1.1", + "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.10.0", "elasticsearch": "^16.7.0", @@ -194,9 +223,11 @@ "expiry-js": "0.1.7", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.1", + "file-saver": "^1.3.8", "file-type": "^10.9.0", "focus-trap-react": "^3.1.1", "font-awesome": "4.7.0", + "formsy-react": "^1.1.5", "fp-ts": "^2.3.1", "geojson-vt": "^3.2.1", "get-port": "^5.0.0", @@ -212,31 +243,51 @@ "graphql-tag": "^2.10.3", "graphql-tools": "^3.0.2", "handlebars": "4.7.7", + "he": "^1.2.0", "history": "^4.9.0", + "history-extra": "^5.0.1", "hjson": "3.2.1", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", + "i18n-iso-countries": "^4.3.1", + "icalendar": "0.7.1", "idx": "^2.5.6", "immer": "^8.0.1", "inline-style": "^2.0.0", "intl": "^1.2.5", "intl-format-cache": "^2.1.0", "intl-messageformat": "^2.2.0", + "intl-messageformat-parser": "^1.4.0", "intl-relativeformat": "^2.1.0", "io-ts": "^2.0.5", "ipaddr.js": "2.0.0", "isbinaryfile": "4.0.2", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", + "js-search": "^1.4.3", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", + "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", "jsonwebtoken": "^8.5.1", + "jsts": "^1.6.2", + "kea": "^2.3.0", + "leaflet": "1.5.1", + "leaflet-draw": "0.4.14", + "leaflet-responsive-popup": "0.6.4", + "leaflet.heat": "0.2.0", + "less": "npm:@elastic/less@2.7.3-kibana", "load-json-file": "^6.2.0", + "loader-utils": "^1.2.3", "lodash": "^4.17.21", "lru-cache": "^4.1.5", + "lz-string": "^1.4.4", "markdown-it": "^10.0.0", + "mapbox-gl": "1.13.1", + "mapbox-gl-draw-rectangle-mode": "^1.0.4", "md5": "^2.1.0", + "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "0.8.0", @@ -261,38 +312,69 @@ "papaparse": "^5.2.0", "pdfmake": "^0.1.65", "pegjs": "0.10.0", + "p-limit": "^3.0.1", + "pluralize": "3.1.0", "pngjs": "^3.4.0", + "polished": "^1.9.2", "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", + "proxyquire": "1.8.0", "puid": "1.0.7", "puppeteer": "npm:@elastic/puppeteer@5.4.1-patch.1", "query-string": "^6.13.2", "raw-loader": "^3.1.0", + "rbush": "^3.0.1", + "re-resizable": "^6.1.1", "re2": "^1.15.4", "react": "^16.12.0", "react-ace": "^5.9.0", + "react-apollo": "^2.1.4", + "react-beautiful-dnd": "^13.0.0", "react-color": "^2.13.8", "react-datetime": "^2.14.0", "react-dom": "^16.12.0", + "react-dropzone": "^4.2.9", + "react-fast-compare": "^2.0.4", + "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", "react-intl": "^2.8.0", "react-is": "^16.8.0", + "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", + "react-monaco-editor": "^0.41.2", + "react-popper-tooltip": "^2.10.1", "react-query": "^3.12.0", + "react-resize-detector": "^4.2.0", + "react-reverse-portal": "^1.0.4", + "react-router-redux": "^4.0.8", + "react-shortcuts": "^2.0.0", + "react-sizeme": "^2.3.6", + "react-syntax-highlighter": "^15.3.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-tiny-virtual-list": "^2.2.0", + "react-virtualized": "^9.21.2", "react-use": "^15.3.8", + "react-vis": "^1.8.1", + "react-visibility-sensor": "^5.1.1", + "reactcss": "1.2.3", "recompose": "^0.26.0", + "reduce-reducers": "^1.0.4", "redux": "^4.0.5", "redux-actions": "^2.6.5", + "redux-devtools-extension": "^2.13.8", "redux-observable": "^1.2.0", + "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", + "redux-thunks": "^1.0.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", + "reselect": "^4.0.0", + "resize-observer-polyfill": "^1.5.0", "rison-node": "1.0.2", "rxjs": "^6.5.5", "seedrandom": "^3.0.5", @@ -305,17 +387,30 @@ "style-it": "^2.1.3", "styled-components": "^5.1.0", "symbol-observable": "^1.2.0", + "suricata-sid-db": "^1.0.2", "tabbable": "1.1.3", "tar": "4.4.13", + "tinycolor2": "1.4.1", "tinygradient": "0.4.3", + "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", "type-detect": "^4.0.8", + "typescript-fsa": "^3.0.0", + "typescript-fsa-reducers": "^1.2.2", "ui-select": "0.19.8", "unified": "^9.2.1", + "unstated": "^2.1.1", + "use-resize-observer": "^6.0.0", "utility-types": "^3.10.0", "uuid": "3.3.2", + "vega": "^5.19.1", + "vega-lite": "^4.17.0", + "vega-schema-url-parser": "^2.1.0", + "vega-spec-injector": "^0.0.2", + "vega-tooltip": "^0.25.0", + "venn.js": "0.2.20", "vinyl": "^2.2.0", "vt-pbf": "^3.1.1", "wellknown": "^0.5.0", @@ -347,13 +442,10 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "25.3.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", - "@elastic/maki": "6.3.0", - "@elastic/ui-ace": "0.2.3", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.5.2", "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", @@ -373,17 +465,11 @@ "@kbn/telemetry-tools": "link:packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", - "@kbn/utility-types": "link:packages/kbn-utility-types", "@loaders.gl/polyfills": "^2.3.5", - "@mapbox/geojson-rewind": "^0.5.0", - "@mapbox/mapbox-gl-draw": "^1.2.0", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", - "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.7.2", "@microsoft/api-extractor": "7.7.0", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@scant/router": "^0.1.0", "@storybook/addon-a11y": "^6.1.20", "@storybook/addon-actions": "^6.1.20", "@storybook/addon-docs": "^6.1.20", @@ -456,7 +542,6 @@ "@types/he": "^1.1.1", "@types/history": "^4.7.3", "@types/hjson": "^2.4.2", - "@types/hoist-non-react-statics": "^3.3.1", "@types/http-proxy": "^1.17.4", "@types/http-proxy-agent": "^2.0.2", "@types/inquirer": "^7.3.1", @@ -476,7 +561,6 @@ "@types/listr": "^0.14.0", "@types/loader-utils": "^1.1.3", "@types/lodash": "^4.14.159", - "@types/log-symbols": "^2.0.0", "@types/lru-cache": "^5.1.0", "@types/mapbox-gl": "^1.9.1", "@types/markdown-it": "^0.0.7", @@ -563,21 +647,13 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", - "@welldone-software/why-did-you-render": "^5.0.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^2.0.4", "aggregate-error": "^3.1.0", - "angular-aria": "^1.8.0", "angular-mocks": "^1.7.9", - "angular-recursion": "^1.0.5", - "angular-route": "^1.8.0", - "angular-sortable-view": "^0.0.17", "antlr4ts-cli": "^0.5.0-alpha.3", "apidoc": "^0.25.0", "apidoc-markdown": "^5.1.8", - "apollo-link": "^1.2.3", - "apollo-link-error": "^1.1.7", - "apollo-link-state": "^0.4.1", "argsplit": "^1.0.5", "autoprefixer": "^9.7.4", "axe-core": "^4.0.2", @@ -590,34 +666,23 @@ "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "^5.6.6", - "base64-js": "^1.3.1", "base64url": "^3.0.1", - "broadcast-channel": "^3.0.3", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "cheerio": "0.22.0", "chromedriver": "^89.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", - "compare-versions": "3.5.1", "compression-webpack-plugin": "^4.0.0", - "constate": "^1.3.2", - "copy-to-clipboard": "^3.0.8", "copy-webpack-plugin": "^6.0.2", "cpy": "^8.1.1", - "cronstrue": "^1.51.0", "css-loader": "^3.4.2", "cypress": "^6.2.1", "cypress-cucumber-preprocessor": "^2.5.2", "cypress-multi-reporters": "^1.4.0", "cypress-pipe": "^2.0.0", "cypress-promise": "^1.1.0", - "d3": "3.5.17", - "d3-cloud": "1.2.5", - "d3-scale": "1.0.7", "debug": "^2.6.9", - "deepmerge": "^4.2.2", "del-cli": "^3.0.1", "delete-empty": "^2.0.0", "dependency-check": "^4.1.0", @@ -654,9 +719,7 @@ "fast-glob": "2.2.7", "fetch-mock": "^7.3.9", "file-loader": "^4.2.0", - "file-saver": "^1.3.8", "form-data": "^4.0.0", - "formsy-react": "^1.1.5", "geckodriver": "^1.22.2", "glob-watcher": "5.0.3", "graphql-code-generator": "^0.18.2", @@ -675,19 +738,10 @@ "gulp-zip": "^5.0.2", "has-ansi": "^3.0.0", "hdr-histogram-js": "^1.2.0", - "he": "^1.2.0", - "highlight.js": "^9.18.5", - "history-extra": "^5.0.1", - "hoist-non-react-statics": "^3.3.2", "html": "1.0.0", "html-loader": "^0.5.5", "http-proxy": "^1.18.1", - "i18n-iso-countries": "^4.3.1", - "icalendar": "0.7.1", - "iedriver": "^3.14.2", - "imports-loader": "^0.8.0", "inquirer": "^7.3.3", - "intl-messageformat-parser": "^1.4.0", "is-glob": "^4.0.1", "is-path-inside": "^3.0.2", "istanbul-instrumenter-loader": "^3.0.1", @@ -697,6 +751,7 @@ "jest-cli": "^26.6.3", "jest-diff": "^26.6.2", "jest-environment-jsdom-thirteen": "^1.0.1", + "jest-environment-jsdom": "^26.6.2", "jest-raw-loader": "^1.0.1", "jest-silent-reporter": "^0.2.1", "jest-snapshot": "^26.6.2", @@ -704,31 +759,14 @@ "jest-styled-components": "^7.0.2", "jest-when": "^2.7.2", "jimp": "^0.14.0", - "js-levenshtein": "^1.1.6", - "js-search": "^1.4.3", "jsdom": "13.1.0", - "json-stringify-pretty-compact": "1.2.0", "json5": "^1.0.1", "jsondiffpatch": "0.4.1", - "jsts": "^1.6.2", - "kea": "^2.3.0", - "keymirror": "0.1.1", - "leaflet": "1.5.1", - "leaflet-draw": "0.4.14", - "leaflet-responsive-popup": "0.6.4", - "leaflet.heat": "0.2.0", - "less": "npm:@elastic/less@2.7.3-kibana", "license-checker": "^16.0.0", "listr": "^0.14.1", "lmdb-store": "^0.9.0", "load-grunt-config": "^3.0.1", - "loader-utils": "^1.2.3", - "log-symbols": "^2.2.0", - "lz-string": "^1.4.4", - "mapbox-gl": "1.13.1", - "mapbox-gl-draw-rectangle-mode": "^1.0.4", "marge": "^1.0.1", - "memoize-one": "^5.0.0", "micromatch": "3.1.10", "minimist": "^1.2.5", "mkdirp": "0.5.1", @@ -740,8 +778,6 @@ "mock-http-server": "1.3.0", "ms-chromium-edge-driver": "^0.2.3", "multimatch": "^4.0.0", - "multistream": "^2.1.1", - "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", "node-sass": "^4.14.1", @@ -749,53 +785,19 @@ "nyc": "^15.0.1", "oboe": "^2.1.4", "ora": "^4.0.4", - "p-limit": "^3.0.1", "parse-link-header": "^1.0.1", "pbf": "3.2.1", "pirates": "^4.0.1", "pixelmatch": "^5.1.0", - "pkg-up": "^2.0.0", - "pluralize": "3.1.0", - "polished": "^1.9.2", "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "postcss-prefix-selector": "^1.7.2", "prettier": "^2.2.0", "pretty-ms": "5.0.0", - "proxyquire": "1.8.0", "q": "^1.5.1", - "querystring": "^0.2.0", - "rbush": "^3.0.1", - "re-resizable": "^6.1.1", - "react-apollo": "^2.1.4", - "react-beautiful-dnd": "^13.0.0", - "react-docgen-typescript-loader": "^3.1.1", - "react-dropzone": "^4.2.9", - "react-fast-compare": "^2.0.4", - "react-grid-layout": "^0.16.2", - "react-markdown": "^4.3.1", - "react-monaco-editor": "^0.41.2", - "react-popper-tooltip": "^2.10.1", - "react-resize-detector": "^4.2.0", - "react-reverse-portal": "^1.0.4", - "react-router-redux": "^4.0.8", - "react-shortcuts": "^2.0.0", - "react-sizeme": "^2.3.6", - "react-syntax-highlighter": "^15.3.1", "react-test-renderer": "^16.12.0", - "react-tiny-virtual-list": "^2.2.0", - "react-virtualized": "^9.21.2", - "react-vis": "^1.8.1", - "react-visibility-sensor": "^5.1.1", - "reactcss": "1.2.3", "read-pkg": "^5.2.0", - "reduce-reducers": "^1.0.4", - "redux-devtools-extension": "^2.13.8", - "redux-saga": "^1.1.3", - "redux-thunks": "^1.0.0", "regenerate": "^1.4.0", - "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", "resolve": "^1.7.1", "rxjs-marbles": "^5.0.6", "sass-loader": "^8.0.2", @@ -815,31 +817,18 @@ "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", "supports-color": "^7.0.0", - "suricata-sid-db": "^1.0.2", "tape": "^5.0.1", "tar-fs": "^2.1.0", "tempy": "^0.3.0", "terminal-link": "^2.1.1", "terser-webpack-plugin": "^2.1.2", - "tinycolor2": "1.4.1", - "topojson-client": "3.0.0", "ts-loader": "^7.0.5", "ts-morph": "^9.1.0", "tsd": "^0.13.1", "typescript": "4.1.3", - "typescript-fsa": "^3.0.0", - "typescript-fsa-reducers": "^1.2.2", "unlazy-loader": "^0.1.3", - "unstated": "^2.1.1", "url-loader": "^2.2.0", - "use-resize-observer": "^6.0.0", "val-loader": "^1.1.1", - "vega": "^5.19.1", - "vega-lite": "^4.17.0", - "vega-schema-url-parser": "^2.1.0", - "vega-spec-injector": "^0.0.2", - "vega-tooltip": "^0.25.0", - "venn.js": "0.2.20", "vinyl-fs": "^3.0.3", "wait-on": "^5.2.1", "watchpack": "^1.6.0", diff --git a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts index 2c36e24453c62..dbc455bbd2f8f 100644 --- a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; @@ -17,7 +17,7 @@ export async function emptyKibanaIndexAction({ log, kbnClient, }: { - client: Client; + client: KibanaClient; log: ToolingLog; kbnClient: KbnClient; }) { diff --git a/packages/kbn-es-archiver/src/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts index 68d5437336023..248c4a65cb20a 100644 --- a/packages/kbn-es-archiver/src/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -11,7 +11,7 @@ import { createReadStream } from 'fs'; import { Readable } from 'stream'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { createPromiseFromStreams, concatStreamProviders } from '@kbn/utils'; import { ES_CLIENT_HEADERS } from '../client_headers'; @@ -48,7 +48,7 @@ export async function loadAction({ name: string; skipExisting: boolean; useCreate: boolean; - client: Client; + client: KibanaClient; dataDir: string; log: ToolingLog; kbnClient: KbnClient; diff --git a/packages/kbn-es-archiver/src/actions/save.ts b/packages/kbn-es-archiver/src/actions/save.ts index 3790e0f013ee0..c90f241a1c639 100644 --- a/packages/kbn-es-archiver/src/actions/save.ts +++ b/packages/kbn-es-archiver/src/actions/save.ts @@ -9,7 +9,7 @@ import { resolve } from 'path'; import { createWriteStream, mkdirSync } from 'fs'; import { Readable, Writable } from 'stream'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { createListStream, createPromiseFromStreams } from '@kbn/utils'; @@ -32,7 +32,7 @@ export async function saveAction({ }: { name: string; indices: string | string[]; - client: Client; + client: KibanaClient; dataDir: string; log: ToolingLog; raw: boolean; diff --git a/packages/kbn-es-archiver/src/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts index b5f259a1496bb..f4e37871a5337 100644 --- a/packages/kbn-es-archiver/src/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -9,7 +9,7 @@ import { resolve } from 'path'; import { createReadStream } from 'fs'; import { Readable, Writable } from 'stream'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; import { createPromiseFromStreams } from '@kbn/utils'; @@ -32,7 +32,7 @@ export async function unloadAction({ kbnClient, }: { name: string; - client: Client; + client: KibanaClient; dataDir: string; log: ToolingLog; kbnClient: KbnClient; diff --git a/packages/kbn-es-archiver/src/client_headers.ts b/packages/kbn-es-archiver/src/client_headers.ts index da240c3ad8318..5733eb9b97543 100644 --- a/packages/kbn-es-archiver/src/client_headers.ts +++ b/packages/kbn-es-archiver/src/client_headers.ts @@ -8,4 +8,4 @@ export const ES_CLIENT_HEADERS = { 'x-elastic-product-origin': 'kibana', -}; +} as const; diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index 68eacb4f3caf2..93ce97efd4c84 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; @@ -20,14 +20,14 @@ import { } from './actions'; interface Options { - client: Client; + client: KibanaClient; dataDir: string; log: ToolingLog; kbnClient: KbnClient; } export class EsArchiver { - private readonly client: Client; + private readonly client: KibanaClient; private readonly dataDir: string; private readonly log: ToolingLog; private readonly kbnClient: KbnClient; diff --git a/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts index cacd224e71421..88e167b3705cb 100644 --- a/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts @@ -7,7 +7,7 @@ */ import { Transform } from 'stream'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { Stats } from '../stats'; import { Progress } from '../progress'; import { ES_CLIENT_HEADERS } from '../../client_headers'; @@ -21,7 +21,7 @@ export function createGenerateDocRecordsStream({ progress, query, }: { - client: Client; + client: KibanaClient; stats: Stats; progress: Progress; query?: Record; diff --git a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts index e105a243cae76..028ff16c9afb2 100644 --- a/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import AggregateError from 'aggregate-error'; import { Writable } from 'stream'; import { Stats } from '../stats'; @@ -14,7 +14,7 @@ import { Progress } from '../progress'; import { ES_CLIENT_HEADERS } from '../../client_headers'; export function createIndexDocRecordsStream( - client: Client, + client: KibanaClient, stats: Stats, progress: Progress, useCreate: boolean = false diff --git a/packages/kbn-es-archiver/src/lib/indices/__mocks__/stubs.ts b/packages/kbn-es-archiver/src/lib/indices/__mocks__/stubs.ts index 59101f5490016..7dde4075dc3f2 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__mocks__/stubs.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__mocks__/stubs.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import sinon from 'sinon'; import { ToolingLog } from '@kbn/dev-utils'; import { Stats } from '../../stats'; @@ -67,7 +67,7 @@ const createEsClientError = (errorType: string) => { const indexAlias = (aliases: Record, index: string) => Object.keys(aliases).find((k) => aliases[k] === index); -type StubClient = Client; +type StubClient = KibanaClient; export const createStubClient = ( existingIndices: string[] = [], diff --git a/packages/kbn-es-archiver/src/lib/indices/create_index_stream.test.ts b/packages/kbn-es-archiver/src/lib/indices/create_index_stream.test.ts index 39e00ff0c72c0..28c8ccd1c28a8 100644 --- a/packages/kbn-es-archiver/src/lib/indices/create_index_stream.test.ts +++ b/packages/kbn-es-archiver/src/lib/indices/create_index_stream.test.ts @@ -125,7 +125,6 @@ describe('esArchiver: createCreateIndexStream()', () => { ]); sinon.assert.calledWith(client.indices.create as sinon.SinonSpy, { - method: 'PUT', index: 'index', body: { settings: undefined, diff --git a/packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts index ca89278305813..b45a8b18a5776 100644 --- a/packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts @@ -9,7 +9,8 @@ import { Transform, Readable } from 'stream'; import { inspect } from 'util'; -import { Client } from '@elastic/elasticsearch'; +import { estypes } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { Stats } from '../stats'; @@ -18,12 +19,9 @@ import { deleteIndex } from './delete_index'; import { ES_CLIENT_HEADERS } from '../../client_headers'; interface DocRecord { - value: { + value: estypes.IndexState & { index: string; type: string; - settings: Record; - mappings: Record; - aliases: Record; }; } @@ -33,7 +31,7 @@ export function createCreateIndexStream({ skipExisting = false, log, }: { - client: Client; + client: KibanaClient; stats: Stats; skipExisting?: boolean; log: ToolingLog; @@ -66,7 +64,6 @@ export function createCreateIndexStream({ await client.indices.create( { - method: 'PUT', index, body: { settings, diff --git a/packages/kbn-es-archiver/src/lib/indices/delete_index.ts b/packages/kbn-es-archiver/src/lib/indices/delete_index.ts index b5641eec4b9da..2a42d52e2ca80 100644 --- a/packages/kbn-es-archiver/src/lib/indices/delete_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/delete_index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { Stats } from '../stats'; import { ES_CLIENT_HEADERS } from '../../client_headers'; @@ -15,7 +15,7 @@ import { ES_CLIENT_HEADERS } from '../../client_headers'; const PENDING_SNAPSHOT_STATUSES = ['INIT', 'STARTED', 'WAITING']; export async function deleteIndex(options: { - client: Client; + client: KibanaClient; stats: Stats; index: string | string[]; log: ToolingLog; @@ -84,7 +84,7 @@ export function isDeleteWhileSnapshotInProgressError(error: any) { * snapshotting this index to complete. */ export async function waitForSnapshotCompletion( - client: Client, + client: KibanaClient, index: string | string[], log: ToolingLog ) { diff --git a/packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts index db065274a7b3b..e1552b5ed1e3b 100644 --- a/packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts @@ -7,7 +7,7 @@ */ import { Transform } from 'stream'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { Stats } from '../stats'; @@ -15,7 +15,7 @@ import { deleteIndex } from './delete_index'; import { cleanKibanaIndices } from './kibana_index'; export function createDeleteIndexStream( - client: Client, + client: KibanaClient, stats: Stats, log: ToolingLog, kibanaPluginIds: string[] diff --git a/packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts b/packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts index 4e0319c52264f..6619f1b3a601e 100644 --- a/packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts @@ -7,11 +7,11 @@ */ import { Transform } from 'stream'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { Stats } from '../stats'; import { ES_CLIENT_HEADERS } from '../../client_headers'; -export function createGenerateIndexRecordsStream(client: Client, stats: Stats) { +export function createGenerateIndexRecordsStream(client: KibanaClient, stats: Stats) { return new Transform({ writableObjectMode: true, readableObjectMode: true, diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index dc49085cbd458..fbef255cd9ee5 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -8,7 +8,7 @@ import { inspect } from 'util'; -import { Client } from '@elastic/elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; import { Stats } from '../stats'; @@ -23,7 +23,7 @@ export async function deleteKibanaIndices({ stats, log, }: { - client: Client; + client: KibanaClient; stats: Stats; log: ToolingLog; }) { @@ -67,22 +67,27 @@ export async function migrateKibanaIndex(kbnClient: KbnClient) { * with .kibana, then filters out any that aren't actually Kibana's core * index (e.g. we don't want to remove .kibana_task_manager or the like). */ -async function fetchKibanaIndices(client: Client) { - const resp = await client.cat.indices( +function isKibanaIndex(index?: string): index is string { + return Boolean( + index && + (/^\.kibana(:?_\d*)?$/.test(index) || + /^\.kibana(_task_manager)?_(pre)?\d+\.\d+\.\d+/.test(index)) + ); +} + +async function fetchKibanaIndices(client: KibanaClient) { + const resp = await client.cat.indices( { index: '.kibana*', format: 'json' }, { headers: ES_CLIENT_HEADERS, } ); - const isKibanaIndex = (index: string) => - /^\.kibana(:?_\d*)?$/.test(index) || - /^\.kibana(_task_manager)?_(pre)?\d+\.\d+\.\d+/.test(index); if (!Array.isArray(resp.body)) { throw new Error(`expected response to be an array ${inspect(resp.body)}`); } - return resp.body.map((x: { index: string }) => x.index).filter(isKibanaIndex); + return resp.body.map((x: { index?: string }) => x.index).filter(isKibanaIndex); } const delay = (delayInMs: number) => new Promise((resolve) => setTimeout(resolve, delayInMs)); @@ -93,7 +98,7 @@ export async function cleanKibanaIndices({ log, kibanaPluginIds, }: { - client: Client; + client: KibanaClient; stats: Stats; log: ToolingLog; kibanaPluginIds: string[]; @@ -149,7 +154,13 @@ export async function cleanKibanaIndices({ stats.deletedIndex('.kibana'); } -export async function createDefaultSpace({ index, client }: { index: string; client: Client }) { +export async function createDefaultSpace({ + index, + client, +}: { + index: string; + client: KibanaClient; +}) { await client.create( { index, diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index a1475985af8df..4949d6d1f9fad 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -68,7 +68,7 @@ module.exports = { ], // The test environment that will be used for testing - testEnvironment: 'jest-environment-jsdom-thirteen', + testEnvironment: 'jest-environment-jsdom', // The glob patterns Jest uses to detect test files testMatch: ['**/*.test.{js,mjs,ts,tsx}'], diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 0694bc4ffdb0f..d82b7b83e8f15 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -13,8 +13,8 @@ import Joi from 'joi'; // valid pattern for ID // enforced camel-case identifiers for consistency const ID_PATTERN = /^[a-zA-Z0-9_]+$/; -const INSPECTING = - process.execArgv.includes('--inspect') || process.execArgv.includes('--inspect-brk'); +// it will search both --inspect and --inspect-brk +const INSPECTING = !!process.execArgv.find((arg) => arg.includes('--inspect')); const urlPartsSchema = () => Joi.object() diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js index 4abbc3d29fe7c..a43d3a09c7d70 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js @@ -62,15 +62,11 @@ function collectCliArgs(config, { installDir, extraKbnOpts }) { const buildArgs = config.get('kbnTestServer.buildArgs') || []; const sourceArgs = config.get('kbnTestServer.sourceArgs') || []; const serverArgs = config.get('kbnTestServer.serverArgs') || []; - const execArgv = process.execArgv || []; return pipe( serverArgs, (args) => (installDir ? args.filter((a) => a !== '--oss') : args), - (args) => - installDir - ? [...buildArgs, ...args] - : [...execArgv, KIBANA_EXEC_PATH, ...sourceArgs, ...args], + (args) => (installDir ? [...buildArgs, ...args] : [KIBANA_EXEC_PATH, ...sourceArgs, ...args]), (args) => args.concat(extraKbnOpts || []) ); } diff --git a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx index f517565434c18..686a201761dcd 100644 --- a/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx +++ b/packages/kbn-test/src/jest/utils/enzyme_helpers.tsx @@ -85,6 +85,7 @@ export function mountWithIntl( childContextTypes, ...props }: { + attachTo?: HTMLElement; context?: any; childContextTypes?: ValidationMap; } = {} diff --git a/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js b/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js index 43b6c90452b81..d472f27395ffb 100644 --- a/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js +++ b/packages/kbn-test/src/legacy_es/legacy_es_test_cluster.js @@ -12,9 +12,9 @@ import { get, toPath } from 'lodash'; import { Cluster } from '@kbn/es'; import { CI_PARALLEL_PROCESS_PREFIX } from '../ci_parallel_process_prefix'; import { esTestConfig } from './es_test_config'; +import { Client } from '@elastic/elasticsearch'; import { KIBANA_ROOT } from '../'; -import * as legacyElasticsearch from 'elasticsearch'; const path = require('path'); const del = require('del'); @@ -102,8 +102,8 @@ export function createLegacyEsTestCluster(options = {}) { * Returns an ES Client to the configured cluster */ getClient() { - return new legacyElasticsearch.Client({ - host: this.getUrl(), + return new Client({ + node: this.getUrl(), }); } diff --git a/packages/kbn-utility-types/package.json b/packages/kbn-utility-types/package.json index a8f6e25276cec..33419ee0f1ec4 100644 --- a/packages/kbn-utility-types/package.json +++ b/packages/kbn-utility-types/package.json @@ -6,7 +6,7 @@ "main": "target", "types": "target/index.d.ts", "kibana": { - "devOnly": true + "devOnly": false }, "scripts": { "build": "../../node_modules/.bin/tsc", diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 13c16691bf12a..34b78bbd7e51e 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -68,6 +68,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { if (opts.ssl) { // @kbn/dev-utils is part of devDependencies + // eslint-disable-next-line import/no-extraneous-dependencies const { CA_CERT_PATH, KBN_KEY_PATH, KBN_CERT_PATH } = require('@kbn/dev-utils'); const customElasticsearchHosts = opts.elasticsearch ? opts.elasticsearch.split(',') diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6279d62d2c40e..cfc2dae0b1c67 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -108,7 +108,9 @@ export class DocLinksService { sum: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-sum-aggregation.html`, top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, }, - runtimeFields: `${ELASTICSEARCH_DOCS}runtime.html`, + runtimeFields: { + mapping: `${ELASTICSEARCH_DOCS}runtime-mapping-fields.html`, + }, scriptedFields: { scriptFields: `${ELASTICSEARCH_DOCS}search-request-script-fields.html`, scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html`, @@ -191,6 +193,7 @@ export class DocLinksService { lens: `${ELASTIC_WEBSITE_URL}what-is/kibana-lens`, lensPanels: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/lens.html`, maps: `${ELASTIC_WEBSITE_URL}maps`, + vega: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/vega.html`, }, observability: { guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`, @@ -284,6 +287,7 @@ export class DocLinksService { registerSourceOnly: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-source-only-repository`, registerUrl: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html#snapshots-read-only-repository`, restoreSnapshot: `${ELASTICSEARCH_DOCS}snapshots-restore-snapshot.html`, + restoreSnapshotApi: `${ELASTICSEARCH_DOCS}restore-snapshot-api.html#restore-snapshot-api-request-body`, }, ingest: { pipelines: `${ELASTICSEARCH_DOCS}ingest.html`, @@ -379,7 +383,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 396bf16cbdc6f..5a5ae253bac7f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -11,6 +11,7 @@ import { ConfigDeprecationProvider } from '@kbn/config'; import { ConfigPath } from '@kbn/config'; import { DetailedPeerCertificate } from 'tls'; import { EnvironmentMode } from '@kbn/config'; +import { estypes } from '@elastic/elasticsearch'; import { EuiBreadcrumb } from '@elastic/eui'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; @@ -558,7 +559,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; @@ -1225,12 +1228,12 @@ export interface SavedObjectsFindOptions { preference?: string; rootSearchFields?: string[]; search?: string; - searchAfter?: unknown[]; + searchAfter?: estypes.Id[]; searchFields?: string[]; // (undocumented) sortField?: string; // (undocumented) - sortOrder?: string; + sortOrder?: estypes.SortOrder; // (undocumented) type: string | string[]; typeToNamespacesMap?: Map; diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index dfd0a9efc90c1..1c28eca1f1dec 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -120,10 +120,10 @@ describe('CoreUsageDataService', () => { body: [ { name: '.kibana_task_manager_1', - 'docs.count': 10, - 'docs.deleted': 10, - 'store.size': 1000, - 'pri.store.size': 2000, + 'docs.count': '10', + 'docs.deleted': '10', + 'store.size': '1000', + 'pri.store.size': '2000', }, ], } as any); @@ -131,10 +131,10 @@ describe('CoreUsageDataService', () => { body: [ { name: '.kibana_1', - 'docs.count': 20, - 'docs.deleted': 20, - 'store.size': 2000, - 'pri.store.size': 4000, + 'docs.count': '20', + 'docs.deleted': '20', + 'store.size': '2000', + 'pri.store.size': '4000', }, ], } as any); diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index b9d8c9fc7e39f..dff68bf1c524f 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -118,10 +118,14 @@ export class CoreUsageDataService implements CoreService; }; -function createApiResponse(opts: Partial = {}): ApiResponse { +function createApiResponse>( + opts: Partial> = {} +): ApiResponse { return { - body: {}, + body: {} as any, statusCode: 200, headers: {}, warnings: [], diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts index 7b442469838f6..636841316941b 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts @@ -11,7 +11,7 @@ import { elasticsearchClientMock } from './mocks'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { retryCallCluster, migrationRetryCallCluster } from './retry_call_cluster'; -const dummyBody = { foo: 'bar' }; +const dummyBody: any = { foo: 'bar' }; const createErrorReturn = (err: any) => elasticsearchClientMock.createErrorTransportRequestPromise(err); @@ -29,7 +29,7 @@ describe('retryCallCluster', () => { client.asyncSearch.get.mockReturnValue(successReturn); - const result = await retryCallCluster(() => client.asyncSearch.get()); + const result = await retryCallCluster(() => client.asyncSearch.get({} as any)); expect(result.body).toEqual(dummyBody); }); @@ -44,7 +44,7 @@ describe('retryCallCluster', () => { ) .mockImplementationOnce(() => successReturn); - const result = await retryCallCluster(() => client.asyncSearch.get()); + const result = await retryCallCluster(() => client.asyncSearch.get({} as any)); expect(result.body).toEqual(dummyBody); }); diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index f00cbb928d631..c802163866423 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -124,7 +124,9 @@ const cookieOptions = { path, }; -describe('Cookie based SessionStorage', () => { +// FLAKY: https://github.com/elastic/kibana/issues/89318 +// https://github.com/elastic/kibana/issues/89319 +describe.skip('Cookie based SessionStorage', () => { describe('#set()', () => { it('Should write to session storage & set cookies', async () => { const { server: innerServer, createRouter } = await server.setup(setupDeps); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 3e336dceb83d7..788c179501a80 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -282,6 +282,9 @@ export type { SavedObjectsClientFactoryProvider, SavedObjectsClosePointInTimeOptions, SavedObjectsClosePointInTimeResponse, + ISavedObjectsPointInTimeFinder, + SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsCreateOptions, SavedObjectsExportResultDetails, SavedObjectsFindResult, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts index 22dcc8022858c..468a761781365 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts @@ -117,6 +117,7 @@ describe('getSortedObjectsForExport()', () => { "keepAlive": "2m", }, "search": undefined, + "searchAfter": undefined, "sortField": "updated_at", "sortOrder": "desc", "type": Array [ @@ -145,7 +146,7 @@ describe('getSortedObjectsForExport()', () => { type = 'index-pattern', }: { attributes?: Record; - sort?: unknown[]; + sort?: string[]; type?: string; } = {} ) { @@ -461,6 +462,7 @@ describe('getSortedObjectsForExport()', () => { "keepAlive": "2m", }, "search": undefined, + "searchAfter": undefined, "sortField": "updated_at", "sortOrder": "desc", "type": Array [ @@ -617,6 +619,7 @@ describe('getSortedObjectsForExport()', () => { "keepAlive": "2m", }, "search": "foo", + "searchAfter": undefined, "sortField": "updated_at", "sortOrder": "desc", "type": Array [ @@ -710,6 +713,7 @@ describe('getSortedObjectsForExport()', () => { "keepAlive": "2m", }, "search": undefined, + "searchAfter": undefined, "sortField": "updated_at", "sortOrder": "desc", "type": Array [ @@ -808,6 +812,7 @@ describe('getSortedObjectsForExport()', () => { "keepAlive": "2m", }, "search": undefined, + "searchAfter": undefined, "sortField": "updated_at", "sortOrder": "desc", "type": Array [ 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 c1c0ea73f0bd3..868efa872d643 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -9,7 +9,7 @@ import { createListStream } from '@kbn/utils'; import { PublicMethodsOf } from '@kbn/utility-types'; import { Logger } from '../../logging'; -import { SavedObject, SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { fetchNestedDependencies } from './fetch_nested_dependencies'; @@ -23,7 +23,6 @@ import { } from './types'; import { SavedObjectsExportError } from './errors'; import { applyExportTransforms } from './apply_export_transforms'; -import { createPointInTimeFinder } from './point_in_time_finder'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -168,18 +167,12 @@ export class SavedObjectsExporter { hasReference, search, }: SavedObjectsExportByTypeOptions) { - const findOptions: SavedObjectsFindOptions = { + const finder = this.#savedObjectsClient.createPointInTimeFinder({ type: types, hasReference, hasReferenceOperator: hasReference ? 'OR' : undefined, search, namespaces: namespace ? [namespace] : undefined, - }; - - const finder = createPointInTimeFinder({ - findOptions, - logger: this.#log, - savedObjectsClient: this.#savedObjectsClient, }); const hits: SavedObjectsFindResult[] = []; diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index d35388ff94749..1952a04ab815c 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -95,7 +95,7 @@ const checkOriginConflict = async ( perPage: 10, fields: ['title'], sortField: 'updated_at', - sortOrder: 'desc', + sortOrder: 'desc' as const, ...(namespace && { namespaces: [namespace] }), }; const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); diff --git a/src/core/server/saved_objects/mappings/types.ts b/src/core/server/saved_objects/mappings/types.ts index fa7531392d122..25fb61de93518 100644 --- a/src/core/server/saved_objects/mappings/types.ts +++ b/src/core/server/saved_objects/mappings/types.ts @@ -102,7 +102,7 @@ export type SavedObjectsFieldMapping = /** @internal */ export interface IndexMapping { - dynamic?: string; + dynamic?: boolean | 'strict'; properties: SavedObjectsMappingProperties; _meta?: IndexMappingMeta; } diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts index 63634bdb1754e..5465da2f620ad 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts @@ -164,6 +164,7 @@ describe('diffMappings', () => { _meta: { migrationMappingPropertyHashes: { foo: 'bar' }, }, + // @ts-expect-error dynamic: 'abcde', properties: {}, }; diff --git a/src/core/server/saved_objects/migrations/core/call_cluster.ts b/src/core/server/saved_objects/migrations/core/call_cluster.ts index bbf39549457d8..f37bbdd14a899 100644 --- a/src/core/server/saved_objects/migrations/core/call_cluster.ts +++ b/src/core/server/saved_objects/migrations/core/call_cluster.ts @@ -12,11 +12,12 @@ * funcationality contained here. */ +import type { estypes } from '@elastic/elasticsearch'; import { IndexMapping } from '../../mappings'; export interface CallCluster { (path: 'bulk', opts: { body: object[] }): Promise; - (path: 'count', opts: CountOpts): Promise<{ count: number; _shards: ShardsInfo }>; + (path: 'count', opts: CountOpts): Promise<{ count: number; _shards: estypes.ShardStatistics }>; (path: 'clearScroll', opts: { scrollId: string }): Promise; (path: 'indices.create', opts: IndexCreationOpts): Promise; (path: 'indices.exists', opts: IndexOpts): Promise; @@ -143,7 +144,7 @@ export interface IndexSettingsResult { } export interface RawDoc { - _id: string; + _id: estypes.Id; _source: any; _type?: string; } @@ -153,14 +154,7 @@ export interface SearchResults { hits: RawDoc[]; }; _scroll_id?: string; - _shards: ShardsInfo; -} - -export interface ShardsInfo { - total: number; - successful: number; - skipped: number; - failed: number; + _shards: estypes.ShardStatistics; } export interface ErrorResponse { diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index bfa686ac0cc47..5cb2a88c4733f 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; import _ from 'lodash'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as Index from './elastic_index'; @@ -33,41 +34,6 @@ describe('ElasticIndex', () => { expect(client.indices.get).toHaveBeenCalledWith({ index: '.kibana-test' }, { ignore: [404] }); }); - test('fails if the index doc type is unsupported', async () => { - client.indices.get.mockImplementation((params) => { - const index = params!.index as string; - return elasticsearchClientMock.createSuccessTransportRequestPromise({ - [index]: { - aliases: { foo: index }, - mappings: { spock: { dynamic: 'strict', properties: { a: 'b' } } }, - }, - }); - }); - - await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( - /cannot be automatically migrated/ - ); - }); - - test('fails if there are multiple root types', async () => { - client.indices.get.mockImplementation((params) => { - const index = params!.index as string; - return elasticsearchClientMock.createSuccessTransportRequestPromise({ - [index]: { - aliases: { foo: index }, - mappings: { - doc: { dynamic: 'strict', properties: { a: 'b' } }, - doctor: { dynamic: 'strict', properties: { a: 'b' } }, - }, - }, - }); - }); - - await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( - /cannot be automatically migrated/ - ); - }); - test('decorates index info with exists and indexName', async () => { client.indices.get.mockImplementation((params) => { const index = params!.index as string; @@ -75,8 +41,9 @@ describe('ElasticIndex', () => { [index]: { aliases: { foo: index }, mappings: { dynamic: 'strict', properties: { a: 'b' } }, + settings: {}, }, - }); + } as estypes.GetIndexResponse); }); const info = await Index.fetchInfo(client, '.baz'); @@ -85,6 +52,7 @@ describe('ElasticIndex', () => { mappings: { dynamic: 'strict', properties: { a: 'b' } }, exists: true, indexName: '.baz', + settings: {}, }); }); }); @@ -134,7 +102,7 @@ describe('ElasticIndex', () => { test('removes existing alias', async () => { client.indices.getAlias.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ - '.my-fanci-index': '.muchacha', + '.my-fanci-index': { aliases: { '.muchacha': {} } }, }) ); @@ -157,7 +125,7 @@ describe('ElasticIndex', () => { test('allows custom alias actions', async () => { client.indices.getAlias.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ - '.my-fanci-index': '.muchacha', + '.my-fanci-index': { aliases: { '.muchacha': {} } }, }) ); @@ -185,14 +153,18 @@ describe('ElasticIndex', () => { test('it creates the destination index, then reindexes to it', async () => { client.indices.getAlias.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ - '.my-fanci-index': '.muchacha', + '.my-fanci-index': { aliases: { '.muchacha': {} } }, }) ); client.reindex.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + task: 'abc', + } as estypes.ReindexResponse) ); client.tasks.get.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + completed: true, + } as estypes.GetTaskResponse) ); const info = { @@ -200,7 +172,7 @@ describe('ElasticIndex', () => { exists: true, indexName: '.ze-index', mappings: { - dynamic: 'strict', + dynamic: 'strict' as const, properties: { foo: { type: 'keyword' } }, }, }; @@ -259,13 +231,16 @@ describe('ElasticIndex', () => { test('throws error if re-index task fails', async () => { client.indices.getAlias.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ - '.my-fanci-index': '.muchacha', + '.my-fanci-index': { aliases: { '.muchacha': {} } }, }) ); client.reindex.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + task: 'abc', + } as estypes.ReindexResponse) ); client.tasks.get.mockResolvedValue( + // @ts-expect-error @elastic/elasticsearch GetTaskResponse requires a `task` property even on errors elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true, error: { @@ -273,7 +248,7 @@ describe('ElasticIndex', () => { reason: 'all shards failed', failed_shards: [], }, - }) + } as estypes.GetTaskResponse) ); const info = { @@ -286,6 +261,7 @@ describe('ElasticIndex', () => { }, }; + // @ts-expect-error await expect(Index.convertToAlias(client, info, '.muchacha', 10)).rejects.toThrow( /Re-index failed \[search_phase_execution_exception\] all shards failed/ ); @@ -319,7 +295,9 @@ describe('ElasticIndex', () => { describe('write', () => { test('writes documents in bulk to the index', async () => { client.bulk.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [] as any[], + } as estypes.BulkResponse) ); const index = '.myalias'; @@ -356,7 +334,7 @@ describe('ElasticIndex', () => { client.bulk.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [{ index: { error: { type: 'shazm', reason: 'dern' } } }], - }) + } as estypes.BulkResponse) ); const index = '.myalias'; diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index e42643565eb4f..a5f3cb36e736b 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -12,11 +12,12 @@ */ import _ from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; import { MigrationEsClient } from './migration_es_client'; import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; -import { AliasAction, RawDoc, ShardsInfo } from './call_cluster'; +import { AliasAction, RawDoc } from './call_cluster'; import { SavedObjectsRawDocSource } from '../../serialization'; const settings = { number_of_shards: 1, auto_expand_replicas: '0-1' }; @@ -46,6 +47,7 @@ export async function fetchInfo(client: MigrationEsClient, index: string): Promi const [indexName, indexInfo] = Object.entries(body)[0]; + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required return assertIsSupportedIndex({ ...indexInfo, exists: true, indexName }); } @@ -142,7 +144,7 @@ export async function write(client: MigrationEsClient, index: string, docs: RawD return; } - const exception: any = new Error(err.index.error!.reason); + const exception: any = new Error(err.index!.error!.reason); exception.detail = err; throw exception; } @@ -322,7 +324,7 @@ function assertIsSupportedIndex(indexInfo: FullIndexInfo) { * Object indices should only ever have a single shard. This is more to handle * instances where customers manually expand the shards of an index. */ -function assertResponseIncludeAllShards({ _shards }: { _shards: ShardsInfo }) { +function assertResponseIncludeAllShards({ _shards }: { _shards: estypes.ShardStatistics }) { if (!_.has(_shards, 'total') || !_.has(_shards, 'successful')) { return; } @@ -375,11 +377,12 @@ async function reindex( await new Promise((r) => setTimeout(r, pollInterval)); const { body } = await client.tasks.get({ - task_id: task, + task_id: String(task), }); - if (body.error) { - const e = body.error; + // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't contain `error` property + const e = body.error; + if (e) { throw new Error(`Re-index failed [${e.type}] ${e.reason} :: ${JSON.stringify(e)}`); } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 0d1939231ce6c..dd295efacf6b8 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -7,6 +7,7 @@ */ import _ from 'lodash'; +import type { estypes } from '@elastic/elasticsearch'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -443,23 +444,28 @@ function withIndex( elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'zeid', _shards: { successful: 1, total: 1 }, - }) + } as estypes.ReindexResponse) ); client.tasks.get.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + completed: true, + } as estypes.GetTaskResponse) ); client.search.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult(0)) + elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult(0) as any) ); client.bulk.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [] as any[], + } as estypes.BulkResponse) ); client.count.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ count: numOutOfDate, _shards: { successful: 1, total: 1 }, - }) + } as estypes.CountResponse) ); + // @ts-expect-error client.scroll.mockImplementation(() => { if (scrollCallCounter <= docs.length) { const result = searchResult(scrollCallCounter); diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 52f155e5d2de2..5bf5ae26f6a0a 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -134,7 +134,7 @@ async function deleteIndexTemplates({ client, log, obsoleteIndexTemplatePattern return; } - const { body: templates } = await client.cat.templates>({ + const { body: templates } = await client.cat.templates({ format: 'json', name: obsoleteIndexTemplatePattern, }); @@ -147,7 +147,7 @@ async function deleteIndexTemplates({ client, log, obsoleteIndexTemplatePattern log.info(`Removing index templates: ${templateNames}`); - return Promise.all(templateNames.map((name) => client.indices.deleteTemplate({ name }))); + return Promise.all(templateNames.map((name) => client.indices.deleteTemplate({ name: name! }))); } /** @@ -185,7 +185,13 @@ async function migrateSourceToDest(context: Context) { await Index.write( client, dest.indexName, - await migrateRawDocs(serializer, documentMigrator.migrateAndConvert, docs, log) + await migrateRawDocs( + serializer, + documentMigrator.migrateAndConvert, + // @ts-expect-error @elastic/elasticsearch `Hit._id` may be a string | number in ES, but we always expect strings in the SO index. + docs, + log + ) ); } } diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index b8accc462df9a..7ead37699980a 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -7,13 +7,13 @@ */ import { take } from 'rxjs/operators'; +import { estypes, errors as esErrors } from '@elastic/elasticsearch'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; -import { errors as esErrors } from '@elastic/elasticsearch'; import { DocumentMigrator } from '../core/document_migrator'; jest.mock('../core/document_migrator', () => { return { @@ -105,10 +105,7 @@ describe('KibanaMigrator', () => { const options = mockOptions(); options.client.cat.templates.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise( - { templates: [] }, - { statusCode: 404 } - ) + elasticsearchClientMock.createSuccessTransportRequestPromise([], { statusCode: 404 }) ); options.client.indices.get.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) @@ -129,7 +126,8 @@ describe('KibanaMigrator', () => { options.client.cat.templates.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise( - { templates: [] }, + // @ts-expect-error + { templates: [] } as CatTemplatesResponse, { statusCode: 404 } ) ); @@ -155,7 +153,8 @@ describe('KibanaMigrator', () => { options.client.cat.templates.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise( - { templates: [] }, + // @ts-expect-error + { templates: [] } as CatTemplatesResponse, { statusCode: 404 } ) ); @@ -193,7 +192,8 @@ describe('KibanaMigrator', () => { options.client.cat.templates.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise( - { templates: [] }, + // @ts-expect-error + { templates: [] } as CatTemplatesResponse, { statusCode: 404 } ) ); @@ -323,7 +323,7 @@ describe('KibanaMigrator', () => { completed: true, error: { type: 'elatsicsearch_exception', reason: 'task failed with an error' }, failures: [], - task: { description: 'task description' }, + task: { description: 'task description' } as any, }) ); @@ -365,15 +365,17 @@ const mockV2MigrationOptions = () => { elasticsearchClientMock.createSuccessTransportRequestPromise({ acknowledged: true }) ); options.client.reindex.mockReturnValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ taskId: 'reindex_task_id' }) + elasticsearchClientMock.createSuccessTransportRequestPromise({ + taskId: 'reindex_task_id', + } as estypes.ReindexResponse) ); options.client.tasks.get.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true, error: undefined, failures: [], - task: { description: 'task description' }, - }) + task: { description: 'task description' } as any, + } as estypes.GetTaskResponse) ); return options; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index d025f104c6e3f..22dfb03815052 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -13,9 +13,10 @@ import { ElasticsearchClientError } from '@elastic/elasticsearch/lib/errors'; import { pipe } from 'fp-ts/lib/pipeable'; import { errors as EsErrors } from '@elastic/elasticsearch'; import { flow } from 'fp-ts/lib/function'; +import type { estypes } from '@elastic/elasticsearch'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; -import { SavedObjectsRawDoc } from '../../serialization'; +import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; import { catchRetryableEsClientErrors, RetryableEsClientError, @@ -56,20 +57,22 @@ export type FetchIndexResponse = Record< export const fetchIndices = ( client: ElasticsearchClient, indicesToFetch: string[] -): TaskEither.TaskEither => () => { - return client.indices - .get( - { - index: indicesToFetch, - 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); -}; +): TaskEither.TaskEither => + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required + () => { + return client.indices + .get( + { + index: indicesToFetch, + 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); + }; /** * Sets a write block in place for the given index. If the response includes @@ -98,7 +101,7 @@ export const setWriteBlock = ( }, { maxRetries: 0 /** handle retry ourselves for now */ } ) - .then((res) => { + .then((res: any) => { return res.body.acknowledged === true ? Either.right('set_write_block_succeeded' as const) : Either.left({ @@ -134,7 +137,11 @@ export const removeWriteBlock = ( // Don't change any existing settings preserve_existing: true, body: { - 'index.blocks.write': false, + index: { + blocks: { + write: false, + }, + }, }, }, { maxRetries: 0 /** handle retry ourselves for now */ } @@ -285,7 +292,7 @@ interface WaitForTaskResponse { error: Option.Option<{ type: string; reason: string; index: string }>; completed: boolean; failures: Option.Option; - description: string; + description?: string; } /** @@ -299,12 +306,7 @@ const waitForTask = ( timeout: string ): TaskEither.TaskEither => () => { return client.tasks - .get<{ - completed: boolean; - response: { failures: any[] }; - task: { description: string }; - error: { type: string; reason: string; index: string }; - }>({ + .get({ task_id: taskId, wait_for_completion: true, timeout, @@ -314,6 +316,7 @@ const waitForTask = ( 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, @@ -359,7 +362,7 @@ export const pickupUpdatedMappings = ( wait_for_completion: false, }) .then(({ body: { task: taskId } }) => { - return Either.right({ taskId }); + return Either.right({ taskId: String(taskId!) }); }) .catch(catchRetryableEsClientErrors); }; @@ -387,7 +390,6 @@ export const reindex = ( .reindex({ // Require targetIndex to be an alias. Prevents a new index from being // created if targetIndex doesn't exist. - // @ts-expect-error This API isn't documented require_alias: requireAlias, body: { // Ignore version conflicts from existing documents @@ -416,7 +418,7 @@ export const reindex = ( wait_for_completion: false, }) .then(({ body: { task: taskId } }) => { - return Either.right({ taskId }); + return Either.right({ taskId: String(taskId) }); }) .catch(catchRetryableEsClientErrors); }; @@ -624,7 +626,7 @@ export const createIndex = ( const aliasesObject = (aliases ?? []).reduce((acc, alias) => { acc[alias] = {}; return acc; - }, {} as Record); + }, {} as Record); return client.indices .create( @@ -727,7 +729,7 @@ export const updateAndPickupMappings = ( 'update_mappings_succeeded' > = () => { return client.indices - .putMapping, IndexMapping>({ + .putMapping({ index, timeout: DEFAULT_TIMEOUT, body: mappings, @@ -774,22 +776,16 @@ export const searchForOutdatedDocuments = ( query: Record ): TaskEither.TaskEither => () => { return client - .search<{ - // when `filter_path` is specified, ES doesn't return empty arrays, so if - // there are no search results res.body.hits will be undefined. - hits?: { - hits?: SavedObjectsRawDoc[]; - }; - }>({ + .search({ index, - // Optimize search performance by sorting by the "natural" index order - sort: ['_doc'], // Return the _seq_no and _primary_term so we can use optimistic // concurrency control for updates seq_no_primary_term: true, size: BATCH_SIZE, body: { query, + // 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, @@ -811,7 +807,9 @@ export const searchForOutdatedDocuments = ( 'hits.hits._primary_term', ], }) - .then((res) => Either.right({ outdatedDocuments: res.body.hits?.hits ?? [] })) + .then((res) => + Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) + ) .catch(catchRetryableEsClientErrors); }; @@ -825,20 +823,7 @@ export const bulkOverwriteTransformedDocuments = ( transformedDocs: SavedObjectsRawDoc[] ): TaskEither.TaskEither => () => { return client - .bulk<{ - took: number; - errors: boolean; - items: [ - { - index: { - _id: string; - status: number; - // the filter_path ensures that only items with errors are returned - error: { type: string; reason: string }; - }; - } - ]; - }>({ + .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 @@ -880,7 +865,7 @@ export const bulkOverwriteTransformedDocuments = ( // 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' + (item) => item.index?.error?.type !== 'version_conflict_engine_exception' ); if (errors.length === 0) { return Either.right('bulk_index_succeeded' as const); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 46cfd935f429b..2c052a87d028b 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -258,7 +258,7 @@ describe('migration actions', () => { index: 'clone_red_then_yellow_index', body: { // Enable all shard allocation so that the index status goes yellow - 'index.routing.allocation.enable': 'all', + index: { routing: { allocation: { enable: 'all' } } }, }, }); indexYellow = true; @@ -500,7 +500,7 @@ describe('migration actions', () => { // Create an index with incompatible mappings await createIndex(client, 'reindex_target_6', { - dynamic: 'false', + dynamic: false, properties: { title: { type: 'integer' } }, // integer is incompatible with string title })(); @@ -926,7 +926,7 @@ describe('migration actions', () => { index: 'red_then_yellow_index', body: { // Disable all shard allocation so that the index status is red - 'index.routing.allocation.enable': 'all', + index: { routing: { allocation: { enable: 'all' } } }, }, }); indexYellow = true; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 95a867934307a..fd62fd107648e 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -162,7 +162,9 @@ describe('migration v2', () => { const expectedVersions = getExpectedVersionPerType(); const res = await esClient.search({ index: migratedIndex, - sort: ['_doc'], + body: { + sort: ['_doc'], + }, size: 10000, }); const allDocuments = res.body.hits.hits as SavedObjectsRawDoc[]; @@ -217,7 +219,9 @@ describe('migration v2', () => { const expectedVersions = getExpectedVersionPerType(); const res = await esClient.search({ index: migratedIndex, - sort: ['_doc'], + body: { + sort: ['_doc'], + }, size: 10000, }); const allDocuments = res.body.hits.hits as SavedObjectsRawDoc[]; diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index 6f915df9dd958..2e92f34429ea9 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -727,7 +727,6 @@ export const createInitialState = ({ }; const reindexTargetMappings: IndexMapping = { - // @ts-expect-error we don't allow plugins to set `dynamic` dynamic: false, properties: { type: { type: 'keyword' }, diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index d589809e38f01..52f8dcd310509 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -274,7 +274,7 @@ describe('SavedObjectsService', () => { expect(coreStart.elasticsearch.client.asScoped).toHaveBeenCalledWith(req); const [ - [, , , , includedHiddenTypes], + [, , , , , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; expect(includedHiddenTypes).toEqual([]); @@ -292,7 +292,7 @@ describe('SavedObjectsService', () => { createScopedRepository(req, ['someHiddenType']); const [ - [, , , , includedHiddenTypes], + [, , , , , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; expect(includedHiddenTypes).toEqual(['someHiddenType']); @@ -311,7 +311,7 @@ describe('SavedObjectsService', () => { createInternalRepository(); const [ - [, , , client, includedHiddenTypes], + [, , , client, , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; expect(coreStart.elasticsearch.client.asInternalUser).toBe(client); @@ -328,7 +328,7 @@ describe('SavedObjectsService', () => { createInternalRepository(['someHiddenType']); const [ - [, , , , includedHiddenTypes], + [, , , , , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; expect(includedHiddenTypes).toEqual(['someHiddenType']); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index fce7f12384456..8e4320eb841f8 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -421,6 +421,7 @@ export class SavedObjectsService this.typeRegistry, kibanaConfig.index, esClient, + this.logger.get('repository'), includedHiddenTypes ); }; diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 1186e15cbef4a..8a66e6176d1f5 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -8,6 +8,9 @@ export { SavedObjectsErrorHelpers, SavedObjectsClientProvider, SavedObjectsUtils } from './lib'; export type { SavedObjectsRepository, + ISavedObjectsPointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsCreatePointInTimeFinderDependencies, ISavedObjectsClientProvider, SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index 16e27bcc12b8f..cef83f103ec53 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -12,7 +12,10 @@ function toArray(value: string | string[]): string[] { /** * Provides an array of paths for ES source filtering */ -export function includedFields(type: string | string[] = '*', fields?: string[] | string) { +export function includedFields( + type: string | string[] = '*', + fields?: string[] | string +): string[] | undefined { if (!fields || fields.length === 0) { return; } diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index d05552bc6e55e..09bce81b14c39 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -8,6 +8,13 @@ export type { ISavedObjectsRepository, SavedObjectsRepository } from './repository'; export { SavedObjectsClientProvider } from './scoped_client_provider'; + +export type { + ISavedObjectsPointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsCreatePointInTimeFinderDependencies, +} from './point_in_time_finder'; + export type { SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, diff --git a/src/core/server/saved_objects/service/lib/point_in_time_finder.mock.ts b/src/core/server/saved_objects/service/lib/point_in_time_finder.mock.ts new file mode 100644 index 0000000000000..c689eb319898b --- /dev/null +++ b/src/core/server/saved_objects/service/lib/point_in_time_finder.mock.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 { loggerMock, MockedLogger } from '../../../logging/logger.mock'; +import type { SavedObjectsClientContract } from '../../types'; +import type { ISavedObjectsRepository } from './repository'; +import { PointInTimeFinder } from './point_in_time_finder'; + +const createPointInTimeFinderMock = ({ + logger = loggerMock.create(), + savedObjectsMock, +}: { + logger?: MockedLogger; + savedObjectsMock: jest.Mocked; +}): jest.Mock => { + const mock = jest.fn(); + + // To simplify testing, we use the actual implementation here, but pass through the + // mocked dependencies. This allows users to set their own `mockResolvedValue` on + // the SO client mock and have it reflected when using `createPointInTimeFinder`. + mock.mockImplementation((findOptions) => { + const finder = new PointInTimeFinder(findOptions, { + logger, + client: savedObjectsMock, + }); + + jest.spyOn(finder, 'find'); + jest.spyOn(finder, 'close'); + + return finder; + }); + + return mock; +}; + +export const savedObjectsPointInTimeFinderMock = { + create: createPointInTimeFinderMock, +}; diff --git a/src/core/server/saved_objects/export/point_in_time_finder.test.ts b/src/core/server/saved_objects/service/lib/point_in_time_finder.test.ts similarity index 57% rename from src/core/server/saved_objects/export/point_in_time_finder.test.ts rename to src/core/server/saved_objects/service/lib/point_in_time_finder.test.ts index cd79c7a4b81e5..044bb45269538 100644 --- a/src/core/server/saved_objects/export/point_in_time_finder.test.ts +++ b/src/core/server/saved_objects/service/lib/point_in_time_finder.test.ts @@ -6,12 +6,15 @@ * Side Public License, v 1. */ -import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; -import { loggerMock, MockedLogger } from '../../logging/logger.mock'; -import { SavedObjectsFindOptions } from '../types'; -import { SavedObjectsFindResult } from '../service'; +import { loggerMock, MockedLogger } from '../../../logging/logger.mock'; +import type { SavedObjectsClientContract } from '../../types'; +import type { SavedObjectsFindResult } from '../'; +import { savedObjectsRepositoryMock } from './repository.mock'; -import { createPointInTimeFinder } from './point_in_time_finder'; +import { + PointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, +} from './point_in_time_finder'; const mockHits = [ { @@ -40,26 +43,31 @@ const mockHits = [ describe('createPointInTimeFinder()', () => { let logger: MockedLogger; - let savedObjectsClient: ReturnType; + let find: jest.Mocked['find']; + let openPointInTimeForType: jest.Mocked['openPointInTimeForType']; + let closePointInTime: jest.Mocked['closePointInTime']; beforeEach(() => { logger = loggerMock.create(); - savedObjectsClient = savedObjectsClientMock.create(); + const mockRepository = savedObjectsRepositoryMock.create(); + find = mockRepository.find; + openPointInTimeForType = mockRepository.openPointInTimeForType; + closePointInTime = mockRepository.closePointInTime; }); describe('#find', () => { test('throws if a PIT is already open', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'abc123', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', @@ -67,31 +75,38 @@ describe('createPointInTimeFinder()', () => { page: 1, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 1, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); await finder.find().next(); - expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); - savedObjectsClient.find.mockClear(); + expect(find).toHaveBeenCalledTimes(1); + find.mockClear(); expect(async () => { await finder.find().next(); }).rejects.toThrowErrorMatchingInlineSnapshot( `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` ); - expect(savedObjectsClient.find).toHaveBeenCalledTimes(0); + expect(find).toHaveBeenCalledTimes(0); }); test('works with a single page of results', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'abc123', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', @@ -99,22 +114,29 @@ describe('createPointInTimeFinder()', () => { page: 0, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const hits: SavedObjectsFindResult[] = []; for await (const result of finder.find()) { hits.push(...result.saved_objects); } expect(hits.length).toBe(2); - expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect(openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(closePointInTime).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith( expect.objectContaining({ pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), sortField: 'updated_at', @@ -125,24 +147,24 @@ describe('createPointInTimeFinder()', () => { }); test('works with multiple pages of results', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'abc123', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [mockHits[0]], pit_id: 'abc123', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [mockHits[1]], pit_id: 'abc123', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [], per_page: 1, @@ -150,25 +172,32 @@ describe('createPointInTimeFinder()', () => { page: 0, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 1, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const hits: SavedObjectsFindResult[] = []; for await (const result of finder.find()) { hits.push(...result.saved_objects); } expect(hits.length).toBe(2); - expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(closePointInTime).toHaveBeenCalledTimes(1); // called 3 times since we need a 3rd request to check if we // are done paginating through results. - expect(savedObjectsClient.find).toHaveBeenCalledTimes(3); - expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect(find).toHaveBeenCalledTimes(3); + expect(find).toHaveBeenCalledWith( expect.objectContaining({ pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), sortField: 'updated_at', @@ -181,10 +210,10 @@ describe('createPointInTimeFinder()', () => { describe('#close', () => { test('calls closePointInTime with correct ID', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'test', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 1, saved_objects: [mockHits[0]], pit_id: 'test', @@ -192,41 +221,48 @@ describe('createPointInTimeFinder()', () => { page: 0, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 2, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const hits: SavedObjectsFindResult[] = []; for await (const result of finder.find()) { hits.push(...result.saved_objects); await finder.close(); } - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + expect(closePointInTime).toHaveBeenCalledWith('test'); }); test('causes generator to stop', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'test', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [mockHits[0]], pit_id: 'test', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [mockHits[1]], pit_id: 'test', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: [], per_page: 1, @@ -234,36 +270,50 @@ describe('createPointInTimeFinder()', () => { page: 0, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 1, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const hits: SavedObjectsFindResult[] = []; for await (const result of finder.find()) { hits.push(...result.saved_objects); await finder.close(); } - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(closePointInTime).toHaveBeenCalledTimes(1); expect(hits.length).toBe(1); }); test('is called if `find` throws an error', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'test', }); - savedObjectsClient.find.mockRejectedValueOnce(new Error('oops')); + find.mockRejectedValueOnce(new Error('oops')); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 2, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const hits: SavedObjectsFindResult[] = []; try { for await (const result of finder.find()) { @@ -273,21 +323,21 @@ describe('createPointInTimeFinder()', () => { // intentionally empty } - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + expect(closePointInTime).toHaveBeenCalledWith('test'); }); test('finder can be reused after closing', async () => { - savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + openPointInTimeForType.mockResolvedValueOnce({ id: 'abc123', }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', per_page: 1, page: 0, }); - savedObjectsClient.find.mockResolvedValueOnce({ + find.mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', @@ -295,13 +345,20 @@ describe('createPointInTimeFinder()', () => { page: 1, }); - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { type: ['visualization'], search: 'foo*', perPage: 1, }; - const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const finder = new PointInTimeFinder(findOptions, { + logger, + client: { + find, + openPointInTimeForType, + closePointInTime, + }, + }); const findA = finder.find(); await findA.next(); @@ -313,9 +370,9 @@ describe('createPointInTimeFinder()', () => { expect((await findA.next()).done).toBe(true); expect((await findB.next()).done).toBe(true); - expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(2); - expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); - expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(2); + expect(openPointInTimeForType).toHaveBeenCalledTimes(2); + expect(find).toHaveBeenCalledTimes(2); + expect(closePointInTime).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/core/server/saved_objects/export/point_in_time_finder.ts b/src/core/server/saved_objects/service/lib/point_in_time_finder.ts similarity index 52% rename from src/core/server/saved_objects/export/point_in_time_finder.ts rename to src/core/server/saved_objects/service/lib/point_in_time_finder.ts index dc0bac6b6bfd9..9a8dcceafebb2 100644 --- a/src/core/server/saved_objects/export/point_in_time_finder.ts +++ b/src/core/server/saved_objects/service/lib/point_in_time_finder.ts @@ -5,80 +5,77 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; +import type { Logger } from '../../../logging'; +import type { SavedObjectsFindOptions, SavedObjectsClientContract } from '../../types'; +import type { SavedObjectsFindResponse } from '../'; -import { Logger } from '../../logging'; -import { SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; -import { SavedObjectsFindResponse } from '../service'; +type PointInTimeFinderClient = Pick< + SavedObjectsClientContract, + 'find' | 'openPointInTimeForType' | 'closePointInTime' +>; /** - * Returns a generator to help page through large sets of saved objects. - * - * The generator wraps calls to `SavedObjects.find` and iterates over - * multiple pages of results using `_pit` and `search_after`. This will - * open a new Point In Time (PIT), and continue paging until a set of - * results is received that's smaller than the designated `perPage`. - * - * Once you have retrieved all of the results you need, it is recommended - * to call `close()` to clean up the PIT and prevent Elasticsearch from - * consuming resources unnecessarily. This will automatically be done for - * you if you reach the last page of results. - * - * @example - * ```ts - * const findOptions: SavedObjectsFindOptions = { - * type: 'visualization', - * search: 'foo*', - * perPage: 100, - * }; - * - * const finder = createPointInTimeFinder({ - * logger, - * savedObjectsClient, - * findOptions, - * }); - * - * const responses: SavedObjectFindResponse[] = []; - * for await (const response of finder.find()) { - * responses.push(...response); - * if (doneSearching) { - * await finder.close(); - * } - * } - * ``` + * @public */ -export function createPointInTimeFinder({ - findOptions, - logger, - savedObjectsClient, -}: { - findOptions: SavedObjectsFindOptions; +export type SavedObjectsCreatePointInTimeFinderOptions = Omit< + SavedObjectsFindOptions, + 'page' | 'pit' | 'searchAfter' +>; + +/** + * @public + */ +export interface SavedObjectsCreatePointInTimeFinderDependencies { + client: Pick; +} + +/** + * @internal + */ +export interface PointInTimeFinderDependencies + extends SavedObjectsCreatePointInTimeFinderDependencies { logger: Logger; - savedObjectsClient: SavedObjectsClientContract; -}) { - return new PointInTimeFinder({ findOptions, logger, savedObjectsClient }); +} + +/** @public */ +export interface ISavedObjectsPointInTimeFinder { + /** + * An async generator which wraps calls to `savedObjectsClient.find` and + * iterates over multiple pages of results using `_pit` and `search_after`. + * This will open a new Point-In-Time (PIT), and continue paging until a set + * of results is received that's smaller than the designated `perPage` size. + */ + find: () => AsyncGenerator; + /** + * Closes the Point-In-Time associated with this finder instance. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This is only required if you are + * done iterating and have not yet paged through all of the results: the + * PIT will automatically be closed for you once you reach the last page + * of results, or if the underlying call to `find` fails for any reason. + */ + close: () => Promise; } /** * @internal */ -export class PointInTimeFinder { +export class PointInTimeFinder implements ISavedObjectsPointInTimeFinder { readonly #log: Logger; - readonly #savedObjectsClient: SavedObjectsClientContract; + readonly #client: PointInTimeFinderClient; readonly #findOptions: SavedObjectsFindOptions; #open: boolean = false; #pitId?: string; - constructor({ - findOptions, - logger, - savedObjectsClient, - }: { - findOptions: SavedObjectsFindOptions; - logger: Logger; - savedObjectsClient: SavedObjectsClientContract; - }) { - this.#log = logger; - this.#savedObjectsClient = savedObjectsClient; + constructor( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + { logger, client }: PointInTimeFinderDependencies + ) { + this.#log = logger.get('point-in-time-finder'); + this.#client = client; this.#findOptions = { // Default to 1000 items per page as a tradeoff between // speed and memory consumption. @@ -99,18 +96,18 @@ export class PointInTimeFinder { await this.open(); let lastResultsCount: number; - let lastHitSortValue: unknown[] | undefined; + let lastHitSortValue: estypes.Id[] | undefined; do { const results = await this.findNext({ findOptions: this.#findOptions, id: this.#pitId, - ...(lastHitSortValue ? { searchAfter: lastHitSortValue } : {}), + searchAfter: lastHitSortValue, }); this.#pitId = results.pit_id; lastResultsCount = results.saved_objects.length; lastHitSortValue = this.getLastHitSortValue(results); - this.#log.debug(`Collected [${lastResultsCount}] saved objects for export.`); + this.#log.debug(`Collected [${lastResultsCount}] saved objects`); // Close PIT if this was our last page if (this.#pitId && lastResultsCount < this.#findOptions.perPage!) { @@ -129,7 +126,7 @@ export class PointInTimeFinder { try { if (this.#pitId) { this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`); - await this.#savedObjectsClient.closePointInTime(this.#pitId); + await this.#client.closePointInTime(this.#pitId); this.#pitId = undefined; } this.#open = false; @@ -141,13 +138,14 @@ export class PointInTimeFinder { private async open() { try { - const { id } = await this.#savedObjectsClient.openPointInTimeForType(this.#findOptions.type); + const { id } = await this.#client.openPointInTimeForType(this.#findOptions.type); this.#pitId = id; this.#open = true; } catch (e) { - // Since `find` swallows 404s, it is expected that exporter will do the same, + // Since `find` swallows 404s, it is expected that finder will do the same, // so we only rethrow non-404 errors here. - if (e.output.statusCode !== 404) { + if (e.output?.statusCode !== 404) { + this.#log.error(`Failed to open PIT for types [${this.#findOptions.type}]`); throw e; } this.#log.debug(`Unable to open PIT for types [${this.#findOptions.type}]: 404 ${e}`); @@ -161,17 +159,17 @@ export class PointInTimeFinder { }: { findOptions: SavedObjectsFindOptions; id?: string; - searchAfter?: unknown[]; + searchAfter?: estypes.Id[]; }) { try { - return await this.#savedObjectsClient.find({ + return await this.#client.find({ // Sort fields are required to use searchAfter, so we set some defaults here sortField: 'updated_at', sortOrder: 'desc', // Bump keep_alive by 2m on every new request to allow for the ES client // to make multiple retries in the event of a network failure. - ...(id ? { pit: { id, keepAlive: '2m' } } : {}), - ...(searchAfter ? { searchAfter } : {}), + pit: id ? { id, keepAlive: '2m' } : undefined, + searchAfter, ...findOptions, }); } catch (e) { @@ -183,7 +181,7 @@ export class PointInTimeFinder { } } - private getLastHitSortValue(res: SavedObjectsFindResponse): unknown[] | undefined { + private getLastHitSortValue(res: SavedObjectsFindResponse): estypes.Id[] | undefined { if (res.saved_objects.length < 1) { return undefined; } diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index a3610b1e437e2..a2092e0571808 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -6,26 +6,36 @@ * Side Public License, v 1. */ +import { savedObjectsPointInTimeFinderMock } from './point_in_time_finder.mock'; import { ISavedObjectsRepository } from './repository'; -const create = (): jest.Mocked => ({ - checkConflicts: jest.fn(), - create: jest.fn(), - bulkCreate: jest.fn(), - bulkUpdate: jest.fn(), - delete: jest.fn(), - bulkGet: jest.fn(), - find: jest.fn(), - get: jest.fn(), - closePointInTime: jest.fn(), - openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), - resolve: jest.fn(), - update: jest.fn(), - addToNamespaces: jest.fn(), - deleteFromNamespaces: jest.fn(), - deleteByNamespace: jest.fn(), - incrementCounter: jest.fn(), - removeReferencesTo: jest.fn(), -}); +const create = () => { + const mock: jest.Mocked = { + checkConflicts: jest.fn(), + create: jest.fn(), + bulkCreate: jest.fn(), + bulkUpdate: jest.fn(), + delete: jest.fn(), + bulkGet: jest.fn(), + find: jest.fn(), + get: jest.fn(), + closePointInTime: jest.fn(), + createPointInTimeFinder: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), + resolve: jest.fn(), + update: jest.fn(), + addToNamespaces: jest.fn(), + deleteFromNamespaces: jest.fn(), + deleteByNamespace: jest.fn(), + incrementCounter: jest.fn(), + removeReferencesTo: jest.fn(), + }; + + mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ + savedObjectsMock: mock, + }); + + return mock; +}; export const savedObjectsRepositoryMock = { create }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index d26d92e84925a..ce48e8dc9a317 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -6,10 +6,14 @@ * Side Public License, v 1. */ +import { pointInTimeFinderMock } from './repository.test.mock'; + import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; +import { PointInTimeFinder } from './point_in_time_finder'; import { ALL_NAMESPACES_STRING } from './utils'; +import { loggerMock } from '../../../logging/logger.mock'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -19,6 +23,7 @@ import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; import { errors as EsErrors } from '@elastic/elasticsearch'; + const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -39,6 +44,7 @@ describe('SavedObjectsRepository', () => { let client; let savedObjectsRepository; let migrator; + let logger; let serializer; const mockTimestamp = '2017-08-14T15:49:14.886Z'; @@ -238,11 +244,13 @@ describe('SavedObjectsRepository', () => { }; beforeEach(() => { + pointInTimeFinderMock.mockClear(); client = elasticsearchClientMock.createElasticsearchClient(); migrator = mockKibanaMigrator.create(); documentMigrator.prepareMigrations(); migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); migrator.runMigrations = async () => ({ status: 'skipped' }); + logger = loggerMock.create(); // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation serializer = { @@ -269,6 +277,7 @@ describe('SavedObjectsRepository', () => { typeRegistry: registry, serializer, allowedTypes, + logger, }); savedObjectsRepository._getCurrentTime = jest.fn(() => mockTimestamp); @@ -2774,18 +2783,20 @@ describe('SavedObjectsRepository', () => { await findSuccess({ type, fields: ['title'] }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ - _source: [ - `${type}.title`, - 'namespace', - 'namespaces', - 'type', - 'references', - 'migrationVersion', - 'coreMigrationVersion', - 'updated_at', - 'originId', - 'title', - ], + body: expect.objectContaining({ + _source: [ + `${type}.title`, + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'coreMigrationVersion', + 'updated_at', + 'originId', + 'title', + ], + }), }), expect.anything() ); @@ -3644,6 +3655,33 @@ describe('SavedObjectsRepository', () => { ); }); + it(`uses the 'upsertAttributes' option when specified`, async () => { + const upsertAttributes = { + foo: 'bar', + hello: 'dolly', + }; + await incrementCounterSuccess(type, id, counterFields, { namespace, upsertAttributes }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + upsert: expect.objectContaining({ + [type]: { + foo: 'bar', + hello: 'dolly', + ...counterFields.reduce((aggs, field) => { + return { + ...aggs, + [field]: 1, + }; + }, {}), + }, + }), + }), + }), + expect.anything() + ); + }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, counterFields, { namespace }); expect(client.update).toHaveBeenCalledWith( @@ -3829,6 +3867,7 @@ describe('SavedObjectsRepository', () => { id: '6.0.0-alpha1', ...mockTimestampFields, version: mockVersion, + references: [], attributes: { buildNum: 8468, apiCallsCount: 100, @@ -4632,4 +4671,31 @@ describe('SavedObjectsRepository', () => { }); }); }); + + describe('#createPointInTimeFinder', () => { + it('returns a new PointInTimeFinder instance', async () => { + const result = await savedObjectsRepository.createPointInTimeFinder({}, {}); + expect(result).toBeInstanceOf(PointInTimeFinder); + }); + + it('calls PointInTimeFinder with the provided options and dependencies', async () => { + const options = Symbol(); + const dependencies = { + client: { + find: Symbol(), + openPointInTimeForType: Symbol(), + closePointInTime: Symbol(), + }, + }; + + await savedObjectsRepository.createPointInTimeFinder(options, dependencies); + expect(pointInTimeFinderMock).toHaveBeenCalledWith( + options, + expect.objectContaining({ + ...dependencies, + logger, + }) + ); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.mock.ts b/src/core/server/saved_objects/service/lib/repository.test.mock.ts new file mode 100644 index 0000000000000..3eba77b465819 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository.test.mock.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 pointInTimeFinderMock = jest.fn(); +jest.doMock('./point_in_time_finder', () => ({ + PointInTimeFinder: pointInTimeFinderMock, +})); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 7a54cdb8488d8..6e2a1d6ec0511 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -7,13 +7,16 @@ */ import { omit, isObject } from 'lodash'; -import { - ElasticsearchClient, - DeleteDocumentResponse, - GetResponse, - SearchResponse, -} from '../../../elasticsearch/'; +import type { estypes } from '@elastic/elasticsearch'; +import type { ElasticsearchClient } from '../../../elasticsearch/'; +import type { Logger } from '../../../logging'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; +import { + ISavedObjectsPointInTimeFinder, + PointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsCreatePointInTimeFinderDependencies, +} from './point_in_time_finder'; import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; @@ -73,10 +76,16 @@ import { // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Left = { tag: 'Left'; error: Record }; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Right = { tag: 'Right'; value: Record }; +interface Left { + tag: 'Left'; + error: Record; +} + +interface Right { + tag: 'Right'; + value: Record; +} + type Either = Left | Right; const isLeft = (either: Either): either is Left => either.tag === 'Left'; const isRight = (either: Either): either is Right => either.tag === 'Right'; @@ -89,12 +98,14 @@ export interface SavedObjectsRepositoryOptions { serializer: SavedObjectsSerializer; migrator: IKibanaMigrator; allowedTypes: string[]; + logger: Logger; } /** * @public */ -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsIncrementCounterOptions + extends SavedObjectsBaseOptions { /** * (default=false) If true, sets all the counter fields to 0 if they don't * already exist. Existing fields will be left as-is and won't be incremented. @@ -107,6 +118,10 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt * operation. See {@link MutatingOperationRefreshSetting} */ refresh?: MutatingOperationRefreshSetting; + /** + * Attributes to use when upserting the document if it doesn't exist. + */ + upsertAttributes?: Attributes; } /** @@ -148,6 +163,7 @@ export class SavedObjectsRepository { private _allowedTypes: string[]; private readonly client: RepositoryEsClient; private _serializer: SavedObjectsSerializer; + private _logger: Logger; /** * A factory function for creating SavedObjectRepository instances. @@ -162,6 +178,7 @@ export class SavedObjectsRepository { typeRegistry: SavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, + logger: Logger, includedHiddenTypes: string[] = [], injectedConstructor: any = SavedObjectsRepository ): ISavedObjectsRepository { @@ -187,6 +204,7 @@ export class SavedObjectsRepository { serializer, allowedTypes, client, + logger, }); } @@ -199,6 +217,7 @@ export class SavedObjectsRepository { serializer, migrator, allowedTypes = [], + logger, } = options; // It's important that we migrate documents / mark them as up-to-date @@ -218,6 +237,7 @@ export class SavedObjectsRepository { } this._allowedTypes = allowedTypes; this._serializer = serializer; + this._logger = logger; } /** @@ -384,7 +404,7 @@ export class SavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( + ? await this.client.mget( { body: { docs: bulkGetDocs, @@ -412,8 +432,9 @@ export class SavedObjectsRepository { if (esRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && actualResult.found === true; - if (docFound && !this.rawDocExistsInNamespace(actualResult, namespace)) { + const docFound = indexFound && actualResult?.found === true; + // @ts-expect-error MultiGetHit._source is optional + if (docFound && !this.rawDocExistsInNamespace(actualResult!, namespace)) { const { id, type } = object; return { tag: 'Left' as 'Left', @@ -428,7 +449,10 @@ export class SavedObjectsRepository { }; } savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); + initialNamespaces || + // @ts-expect-error MultiGetHit._source is optional + getSavedObjectNamespaces(namespace, docFound ? actualResult : undefined); + // @ts-expect-error MultiGetHit._source is optional versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { @@ -487,7 +511,7 @@ export class SavedObjectsRepository { const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; const { error, ...rawResponse } = Object.values( - bulkResponse?.body.items[esRequestIndex] + bulkResponse?.body.items[esRequestIndex] ?? {} )[0] as any; if (error) { @@ -551,10 +575,10 @@ export class SavedObjectsRepository { const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: { includes: ['type', 'namespaces'] }, })); const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( + ? await this.client.mget( { body: { docs: bulkGetDocs, @@ -573,13 +597,14 @@ export class SavedObjectsRepository { const { type, id, esRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[esRequestIndex]; - if (doc.found) { + if (doc?.found) { errors.push({ id, type, error: { ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - ...(!this.rawDocExistsInNamespace(doc, namespace) && { + // @ts-expect-error MultiGetHit._source is optional + ...(!this.rawDocExistsInNamespace(doc!, namespace) && { metadata: { isNotOverwritable: true }, }), }, @@ -623,7 +648,7 @@ export class SavedObjectsRepository { } } - const { body, statusCode } = await this.client.delete( + const { body, statusCode } = await this.client.delete( { id: rawId, index: this.getIndexForType(type), @@ -639,6 +664,7 @@ export class SavedObjectsRepository { } const deleteDocNotFound = body.result === 'not_found'; + // @ts-expect-error 'error' does not exist on type 'DeleteResponse' const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above @@ -800,15 +826,18 @@ export class SavedObjectsRepository { const esOptions = { // If `pit` is provided, we drop the `index`, otherwise ES returns 400. - ...(pit ? {} : { index: this.getIndicesForTypes(allowedTypes) }), + index: pit ? undefined : this.getIndicesForTypes(allowedTypes), // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. - ...(searchAfter ? {} : { from: perPage * (page - 1) }), + from: searchAfter ? undefined : perPage * (page - 1), _source: includedFields(type, fields), preference, rest_total_hits_as_int: true, size: perPage, body: { + size: perPage, seq_no_primary_term: true, + from: perPage * (page - 1), + _source: includedFields(type, fields), ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, @@ -828,7 +857,7 @@ export class SavedObjectsRepository { }, }; - const { body, statusCode } = await this.client.search>(esOptions, { + const { body, statusCode } = await this.client.search(esOptions, { ignore: [404], }); if (statusCode === 404) { @@ -847,13 +876,15 @@ export class SavedObjectsRepository { per_page: perPage, total: body.hits.total, saved_objects: body.hits.hits.map( - (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ + (hit: estypes.Hit): SavedObjectsFindResult => ({ + // @ts-expect-error @elastic/elasticsearch declared Id as string | number ...this._rawToSavedObject(hit), - score: (hit as any)._score, - ...((hit as any).sort && { sort: (hit as any).sort }), + score: hit._score!, + // @ts-expect-error @elastic/elasticsearch declared sort as string | number + sort: hit.sort, }) ), - ...(body.pit_id && { pit_id: body.pit_id }), + pit_id: body.pit_id, } as SavedObjectsFindResponse; } @@ -912,10 +943,10 @@ export class SavedObjectsRepository { .map(({ value: { type, id, fields } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: includedFields(type, fields), + _source: { includes: includedFields(type, fields) }, })); const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( + ? await this.client.mget( { body: { docs: bulkGetDocs, @@ -934,7 +965,8 @@ export class SavedObjectsRepository { const { type, id, esRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[esRequestIndex]; - if (!doc.found || !this.rawDocExistsInNamespace(doc, namespace)) { + // @ts-expect-error MultiGetHit._source is optional + if (!doc?.found || !this.rawDocExistsInNamespace(doc, namespace)) { return ({ id, type, @@ -942,6 +974,7 @@ export class SavedObjectsRepository { } as any) as SavedObject; } + // @ts-expect-error MultiGetHit._source is optional return this.getSavedObjectFromSource(type, id, doc); }), }; @@ -967,7 +1000,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); - const { body, statusCode } = await this.client.get>( + const { body, statusCode } = await this.client.get( { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), @@ -975,9 +1008,13 @@ export class SavedObjectsRepository { { ignore: [404] } ); - const docNotFound = body.found === false; const indexNotFound = statusCode === 404; - if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(body, namespace)) { + + if ( + !isFoundGetResponse(body) || + indexNotFound || + !this.rawDocExistsInNamespace(body, namespace) + ) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1013,7 +1050,7 @@ export class SavedObjectsRepository { const time = this._getCurrentTime(); // retrieve the alias, and if it is not disabled, update it - const aliasResponse = await this.client.update( + const aliasResponse = await this.client.update<{ 'legacy-url-alias': LegacyUrlAlias }>( { id: rawAliasId, index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), @@ -1046,15 +1083,16 @@ export class SavedObjectsRepository { if ( aliasResponse.statusCode === 404 || - aliasResponse.body.get.found === false || - aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true + aliasResponse.body.get?.found === false || + aliasResponse.body.get?._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true ) { // no legacy URL alias exists, or one exists but it's disabled; just attempt to get the object return this.resolveExactMatch(type, id, options); } - const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get._source[LEGACY_URL_ALIAS_TYPE]; + + const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get!._source[LEGACY_URL_ALIAS_TYPE]; const objectIndex = this.getIndexForType(type); - const bulkGetResponse = await this.client.mget( + const bulkGetResponse = await this.client.mget( { body: { docs: [ @@ -1077,23 +1115,28 @@ export class SavedObjectsRepository { const exactMatchDoc = bulkGetResponse?.body.docs[0]; const aliasMatchDoc = bulkGetResponse?.body.docs[1]; const foundExactMatch = + // @ts-expect-error MultiGetHit._source is optional exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace); const foundAliasMatch = + // @ts-expect-error MultiGetHit._source is optional aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); if (foundExactMatch && foundAliasMatch) { return { + // @ts-expect-error MultiGetHit._source is optional saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { + // @ts-expect-error MultiGetHit._source is optional saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'exactMatch', }; } else if (foundAliasMatch) { return { + // @ts-expect-error MultiGetHit._source is optional saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', aliasTargetId: legacyUrlAlias.targetId, @@ -1140,7 +1183,7 @@ export class SavedObjectsRepository { }; const { body } = await this.client - .update({ + .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), ...getExpectedVersionProperties(version, preflightResult), @@ -1160,11 +1203,11 @@ export class SavedObjectsRepository { throw err; }); - const { originId } = body.get._source; - let namespaces = []; + const { originId } = body.get?._source ?? {}; + let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = body.get._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(body.get._source.namespace), + namespaces = body.get?._source.namespaces ?? [ + SavedObjectsUtils.namespaceIdToString(body.get?._source.namespace), ]; } @@ -1172,7 +1215,6 @@ export class SavedObjectsRepository { id, type, updated_at: time, - // @ts-expect-error update doesn't have _seq_no, _primary_term as Record / any in LP version: encodeHitVersion(body), namespaces, ...(originId && { originId }), @@ -1312,7 +1354,7 @@ export class SavedObjectsRepository { return { namespaces: doc.namespaces }; } else { // if there are no namespaces remaining, delete the saved object - const { body, statusCode } = await this.client.delete( + const { body, statusCode } = await this.client.delete( { id: this._serializer.generateRawId(undefined, type, id), refresh, @@ -1330,6 +1372,7 @@ export class SavedObjectsRepository { } const deleteDocNotFound = body.result === 'not_found'; + // @ts-expect-error const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above @@ -1464,9 +1507,10 @@ export class SavedObjectsRepository { if (esRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && actualResult.found === true; + const docFound = indexFound && actualResult?.found === true; if ( !docFound || + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) ) { return { @@ -1478,10 +1522,13 @@ export class SavedObjectsRepository { }, }; } - namespaces = actualResult._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(actualResult._source.namespace), + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + namespaces = actualResult!._source.namespaces ?? [ + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), ]; - versionProperties = getExpectedVersionProperties(version, actualResult); + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + versionProperties = getExpectedVersionProperties(version, actualResult!); } else { if (this._registry.isSingleNamespace(type)) { // if `objectNamespace` is undefined, fall back to `options.namespace` @@ -1530,7 +1577,7 @@ export class SavedObjectsRepository { } const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; - const response = bulkUpdateResponse?.body.items[esRequestIndex]; + const response = bulkUpdateResponse?.body.items[esRequestIndex] ?? {}; // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. const { error, _seq_no: seqNo, _primary_term: primaryTerm, get } = Object.values( @@ -1623,7 +1670,7 @@ export class SavedObjectsRepository { } return { - updated: body.updated, + updated: body.updated!, }; } @@ -1658,6 +1705,20 @@ export class SavedObjectsRepository { * .incrementCounter('dashboard_counter_type', 'counter_id', [ * 'stats.apiCalls', * ]) + * + * // Increment the apiCalls field counter by 4 + * repository + * .incrementCounter('dashboard_counter_type', 'counter_id', [ + * { fieldName: 'stats.apiCalls' incrementBy: 4 }, + * ]) + * + * // Initialize the document with arbitrary fields if not present + * repository.incrementCounter<{ appId: string }>( + * 'dashboard_counter_type', + * 'counter_id', + * [ 'stats.apiCalls'], + * { upsertAttributes: { appId: 'myId' } } + * ) * ``` * * @param type - The type of saved object whose fields should be incremented @@ -1670,7 +1731,7 @@ export class SavedObjectsRepository { type: string, id: string, counterFields: Array, - options: SavedObjectsIncrementCounterOptions = {} + options: SavedObjectsIncrementCounterOptions = {} ): Promise> { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); @@ -1692,12 +1753,16 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } - const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING, initialize = false } = options; + const { + migrationVersion, + refresh = DEFAULT_REFRESH_SETTING, + initialize = false, + upsertAttributes, + } = options; const normalizedCounterFields = counterFields.map((counterField) => { const fieldName = typeof counterField === 'string' ? counterField : counterField.fieldName; const incrementBy = typeof counterField === 'string' ? 1 : counterField.incrementBy || 1; - return { fieldName, incrementBy: initialize ? 0 : incrementBy, @@ -1721,18 +1786,21 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - attributes: normalizedCounterFields.reduce((acc, counterField) => { - const { fieldName, incrementBy } = counterField; - acc[fieldName] = incrementBy; - return acc; - }, {} as Record), + attributes: { + ...(upsertAttributes ?? {}), + ...normalizedCounterFields.reduce((acc, counterField) => { + const { fieldName, incrementBy } = counterField; + acc[fieldName] = incrementBy; + return acc; + }, {} as Record), + }, migrationVersion, updated_at: time, }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - const { body } = await this.client.update({ + const { body } = await this.client.update({ id: raw._id, index: this.getIndexForType(type), refresh, @@ -1770,17 +1838,16 @@ export class SavedObjectsRepository { }, }); - const { originId } = body.get._source; + const { originId } = body.get?._source ?? {}; return { id, type, ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), ...(originId && { originId }), updated_at: time, - references: body.get._source.references, - // @ts-expect-error + references: body.get?._source.references ?? [], version: encodeHitVersion(body), - attributes: body.get._source[type], + attributes: body.get?._source[type], }; } @@ -1788,6 +1855,9 @@ export class SavedObjectsRepository { * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. * + * Only use this API if you have an advanced use case that's not solved by the + * {@link SavedObjectsRepository.createPointInTimeFinder} method. + * * @example * ```ts * const { id } = await savedObjectsClient.openPointInTimeForType( @@ -1836,9 +1906,13 @@ export class SavedObjectsRepository { const { body, statusCode, - } = await this.client.openPointInTime(esOptions, { - ignore: [404], - }); + } = await this.client.openPointInTime( + // @ts-expect-error @elastic/elasticsearch OpenPointInTimeRequest.index expected to accept string[] + esOptions, + { + ignore: [404], + } + ); if (statusCode === 404) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(); } @@ -1853,6 +1927,9 @@ export class SavedObjectsRepository { * via the Elasticsearch client, and is included in the Saved Objects Client * as a convenience for consumers who are using `openPointInTimeForType`. * + * Only use this API if you have an advanced use case that's not solved by the + * {@link SavedObjectsRepository.createPointInTimeFinder} method. + * * @remarks * While the `keepAlive` that is provided will cause a PIT to automatically close, * it is highly recommended to explicitly close a PIT when you are done with it @@ -1893,9 +1970,66 @@ export class SavedObjectsRepository { const { body } = await this.client.closePointInTime({ body: { id }, }); + return body; } + /** + * Returns a {@link ISavedObjectsPointInTimeFinder} to help page through + * large sets of saved objects. We strongly recommend using this API for + * any `find` queries that might return more than 1000 saved objects, + * however this API is only intended for use in server-side "batch" + * processing of objects where you are collecting all objects in memory + * or streaming them back to the client. + * + * Do NOT use this API in a route handler to facilitate paging through + * saved objects on the client-side unless you are streaming all of the + * results back to the client at once. Because the returned generator is + * stateful, you cannot rely on subsequent http requests retrieving new + * pages from the same Kibana server in multi-instance deployments. + * + * This generator wraps calls to {@link SavedObjectsRepository.find} and + * iterates over multiple pages of results using `_pit` and `search_after`. + * This will open a new Point-In-Time (PIT), and continue paging until a + * set of results is received that's smaller than the designated `perPage`. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This is only required if you are + * done iterating and have not yet paged through all of the results: the + * PIT will automatically be closed for you once you reach the last page + * of results, or if the underlying call to `find` fails for any reason. + * + * @example + * ```ts + * const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + * type: 'visualization', + * search: 'foo*', + * perPage: 100, + * }; + * + * const finder = savedObjectsClient.createPointInTimeFinder(findOptions); + * + * const responses: SavedObjectFindResponse[] = []; + * for await (const response of finder.find()) { + * responses.push(...response); + * if (doneSearching) { + * await finder.close(); + * } + * } + * ``` + */ + createPointInTimeFinder( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): ISavedObjectsPointInTimeFinder { + return new PointInTimeFinder(findOptions, { + logger: this._logger, + client: this, + ...dependencies, + }); + } + /** * Returns index specified by the given type or the default index * @@ -1979,7 +2113,7 @@ export class SavedObjectsRepository { throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); } - const { body, statusCode } = await this.client.get>( + const { body, statusCode } = await this.client.get( { id: this._serializer.generateRawId(undefined, type, id), index: this.getIndexForType(type), @@ -1990,8 +2124,7 @@ export class SavedObjectsRepository { ); const indexFound = statusCode !== 404; - const docFound = indexFound && body.found === true; - if (docFound) { + if (indexFound && isFoundGetResponse(body)) { if (!this.rawDocExistsInNamespace(body, namespace)) { throw SavedObjectsErrorHelpers.createConflictError(type, id); } @@ -2016,7 +2149,7 @@ export class SavedObjectsRepository { } const rawId = this._serializer.generateRawId(undefined, type, id); - const { body, statusCode } = await this.client.get>( + const { body, statusCode } = await this.client.get( { id: rawId, index: this.getIndexForType(type), @@ -2025,17 +2158,20 @@ export class SavedObjectsRepository { ); const indexFound = statusCode !== 404; - const docFound = indexFound && body.found === true; - if (!docFound || !this.rawDocExistsInNamespace(body, namespace)) { + if ( + !indexFound || + !isFoundGetResponse(body) || + !this.rawDocExistsInNamespace(body, namespace) + ) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return body as SavedObjectsRawDoc; + return body; } private getSavedObjectFromSource( type: string, id: string, - doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource } + doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { const { originId, updated_at: updatedAt } = doc._source; @@ -2145,3 +2281,15 @@ const normalizeNamespace = (namespace?: string) => { const errorContent = (error: DecoratedError) => error.output.payload; const unique = (array: string[]) => [...new Set(array)]; + +/** + * Type and type guard function for converting a possibly not existant doc to an existant doc. + */ +type GetResponseFound = estypes.GetResponse & + Required< + Pick, '_primary_term' | '_seq_no' | '_version' | '_source'> + >; + +const isFoundGetResponse = ( + doc: estypes.GetResponse +): doc is GetResponseFound => doc.found; diff --git a/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts b/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts index 26aa152c630ad..9d9a2eb14b495 100644 --- a/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts +++ b/src/core/server/saved_objects/service/lib/repository_create_repository.test.ts @@ -10,12 +10,14 @@ import { SavedObjectsRepository } from './repository'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { KibanaMigrator } from '../../migrations'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { loggerMock, MockedLogger } from '../../../logging/logger.mock'; jest.mock('./repository'); const { SavedObjectsRepository: originalRepository } = jest.requireActual('./repository'); describe('SavedObjectsRepository#createRepository', () => { + let logger: MockedLogger; const callAdminCluster = jest.fn(); const typeRegistry = new SavedObjectTypeRegistry(); @@ -59,6 +61,7 @@ describe('SavedObjectsRepository#createRepository', () => { const RepositoryConstructor = (SavedObjectsRepository as unknown) as jest.Mock; beforeEach(() => { + logger = loggerMock.create(); RepositoryConstructor.mockClear(); }); @@ -69,6 +72,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry, '.kibana-test', callAdminCluster, + logger, ['unMappedType1', 'unmappedType2'] ); } catch (e) { @@ -84,6 +88,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry, '.kibana-test', callAdminCluster, + logger, [], SavedObjectsRepository ); @@ -102,6 +107,7 @@ describe('SavedObjectsRepository#createRepository', () => { typeRegistry, '.kibana-test', callAdminCluster, + logger, ['hiddenType', 'hiddenType', 'hiddenType'], SavedObjectsRepository ); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 267d671361184..b15560b82ab31 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -86,7 +86,7 @@ describe('getSearchDsl', () => { const opts = { type: 'foo', sortField: 'bar', - sortOrder: 'baz', + sortOrder: 'asc' as const, pit: { id: 'abc123' }, }; @@ -109,10 +109,10 @@ describe('getSearchDsl', () => { it('returns searchAfter if provided', () => { getQueryParams.mockReturnValue({ a: 'a' }); getSortingParams.mockReturnValue({ b: 'b' }); - expect(getSearchDsl(mappings, registry, { type: 'foo', searchAfter: [1, 'bar'] })).toEqual({ + expect(getSearchDsl(mappings, registry, { type: 'foo', searchAfter: ['1', 'bar'] })).toEqual({ a: 'a', b: 'b', - search_after: [1, 'bar'], + search_after: ['1', 'bar'], }); }); @@ -123,14 +123,14 @@ describe('getSearchDsl', () => { expect( getSearchDsl(mappings, registry, { type: 'foo', - searchAfter: [1, 'bar'], + searchAfter: ['1', 'bar'], pit: { id: 'abc123' }, }) ).toEqual({ a: 'a', b: 'b', pit: { id: 'abc123' }, - search_after: [1, 'bar'], + search_after: ['1', 'bar'], }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 9820544f02bd1..64b3dd428fb8b 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -8,6 +8,7 @@ import Boom from '@hapi/boom'; +import type { estypes } from '@elastic/elasticsearch'; import { IndexMapping } from '../../../mappings'; import { SavedObjectsPitParams } from '../../../types'; import { getQueryParams, HasReferenceQueryParams, SearchOperator } from './query_params'; @@ -23,9 +24,9 @@ interface GetSearchDslOptions { defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; - searchAfter?: unknown[]; + searchAfter?: estypes.Id[]; sortField?: string; - sortOrder?: string; + sortOrder?: estypes.SortOrder; namespaces?: string[]; pit?: SavedObjectsPitParams; typeToNamespacesMap?: Map; @@ -80,6 +81,6 @@ export function getSearchDsl( }), ...getSortingParams(mappings, type, sortField, sortOrder), ...(pit ? getPitParams(pit) : {}), - ...(searchAfter ? { search_after: searchAfter } : {}), + search_after: searchAfter, }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index e3bfba6a80f59..64849c308f3f0 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; @@ -15,8 +16,8 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string -) { + sortOrder?: estypes.SortOrder +): { sort?: estypes.SortContainer[] } { if (!sortField) { return {}; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index ecca652cace37..544e92e32f1a1 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -8,9 +8,10 @@ import { SavedObjectsClientContract } from '../types'; import { SavedObjectsErrorHelpers } from './lib/errors'; +import { savedObjectsPointInTimeFinderMock } from './lib/point_in_time_finder.mock'; -const create = () => - (({ +const create = () => { + const mock = ({ errors: SavedObjectsErrorHelpers, create: jest.fn(), bulkCreate: jest.fn(), @@ -21,12 +22,20 @@ const create = () => find: jest.fn(), get: jest.fn(), closePointInTime: jest.fn(), + createPointInTimeFinder: jest.fn(), openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), removeReferencesTo: jest.fn(), - } as unknown) as jest.Mocked); + } as unknown) as jest.Mocked; + + mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ + savedObjectsMock: mock, + }); + + return mock; +}; export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 7cbddaf195dc9..29381c7e418b5 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -54,6 +54,45 @@ test(`#bulkCreate`, async () => { expect(result).toBe(returnValue); }); +describe(`#createPointInTimeFinder`, () => { + test(`calls repository with options and default dependencies`, () => { + const returnValue = Symbol(); + const mockRepository = { + createPointInTimeFinder: jest.fn().mockReturnValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const options = Symbol(); + const result = client.createPointInTimeFinder(options); + + expect(mockRepository.createPointInTimeFinder).toHaveBeenCalledWith(options, { + client, + }); + expect(result).toBe(returnValue); + }); + + test(`calls repository with options and custom dependencies`, () => { + const returnValue = Symbol(); + const mockRepository = { + createPointInTimeFinder: jest.fn().mockReturnValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const options = Symbol(); + const dependencies = { + client: { + find: Symbol(), + openPointInTimeForType: Symbol(), + closePointInTime: Symbol(), + }, + }; + const result = client.createPointInTimeFinder(options, dependencies); + + expect(mockRepository.createPointInTimeFinder).toHaveBeenCalledWith(options, dependencies); + expect(result).toBe(returnValue); + }); +}); + test(`#delete`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b078f3eef018c..9a0ccb88d3555 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -6,7 +6,12 @@ * Side Public License, v 1. */ -import { ISavedObjectsRepository } from './lib'; +import type { + ISavedObjectsRepository, + ISavedObjectsPointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsCreatePointInTimeFinderDependencies, +} from './lib'; import { SavedObject, SavedObjectError, @@ -157,7 +162,7 @@ export interface SavedObjectsFindResult extends SavedObject { * await savedObjectsClient.closePointInTime(page2.pit_id); * ``` */ - sort?: unknown[]; + sort?: string[]; } /** @@ -587,6 +592,9 @@ export class SavedObjectsClient { * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. * The returned `id` can then be passed to {@link SavedObjectsClient.find} to search * against that PIT. + * + * Only use this API if you have an advanced use case that's not solved by the + * {@link SavedObjectsClient.createPointInTimeFinder} method. */ async openPointInTimeForType( type: string | string[], @@ -599,8 +607,67 @@ export class SavedObjectsClient { * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the * Elasticsearch client, and is included in the Saved Objects Client as a convenience * for consumers who are using {@link SavedObjectsClient.openPointInTimeForType}. + * + * Only use this API if you have an advanced use case that's not solved by the + * {@link SavedObjectsClient.createPointInTimeFinder} method. */ async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { return await this._repository.closePointInTime(id, options); } + + /** + * Returns a {@link ISavedObjectsPointInTimeFinder} to help page through + * large sets of saved objects. We strongly recommend using this API for + * any `find` queries that might return more than 1000 saved objects, + * however this API is only intended for use in server-side "batch" + * processing of objects where you are collecting all objects in memory + * or streaming them back to the client. + * + * Do NOT use this API in a route handler to facilitate paging through + * saved objects on the client-side unless you are streaming all of the + * results back to the client at once. Because the returned generator is + * stateful, you cannot rely on subsequent http requests retrieving new + * pages from the same Kibana server in multi-instance deployments. + * + * The generator wraps calls to {@link SavedObjectsClient.find} and iterates + * over multiple pages of results using `_pit` and `search_after`. This will + * open a new Point-In-Time (PIT), and continue paging until a set of + * results is received that's smaller than the designated `perPage`. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This is only required if you are + * done iterating and have not yet paged through all of the results: the + * PIT will automatically be closed for you once you reach the last page + * of results, or if the underlying call to `find` fails for any reason. + * + * @example + * ```ts + * const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + * type: 'visualization', + * search: 'foo*', + * perPage: 100, + * }; + * + * const finder = savedObjectsClient.createPointInTimeFinder(findOptions); + * + * const responses: SavedObjectFindResponse[] = []; + * for await (const response of finder.find()) { + * responses.push(...response); + * if (doneSearching) { + * await finder.close(); + * } + * } + * ``` + */ + createPointInTimeFinder( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): ISavedObjectsPointInTimeFinder { + return this._repository.createPointInTimeFinder(findOptions, { + client: this, + // Include dependencies last so that SO client wrappers have their settings applied. + ...dependencies, + }); + } } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 11a694c72f29f..ecda120e025d8 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; import { SavedObjectsClient } from './service/saved_objects_client'; import { SavedObjectsTypeMappingDefinition } from './mappings'; import { SavedObjectMigrationMap } from './migrations'; @@ -79,7 +80,7 @@ export interface SavedObjectsFindOptions { page?: number; perPage?: number; sortField?: string; - sortOrder?: string; + sortOrder?: estypes.SortOrder; /** * An array of fields to include in the results * @example @@ -93,7 +94,7 @@ export interface SavedObjectsFindOptions { /** * Use the sort values from the previous page to retrieve the next page of results. */ - searchAfter?: unknown[]; + searchAfter?: estypes.Id[]; /** * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not * be modified. If used in conjunction with `searchFields`, both are concatenated together. diff --git a/src/core/server/saved_objects/version/encode_hit_version.ts b/src/core/server/saved_objects/version/encode_hit_version.ts index 614666c6e1da6..979df93dc57b5 100644 --- a/src/core/server/saved_objects/version/encode_hit_version.ts +++ b/src/core/server/saved_objects/version/encode_hit_version.ts @@ -12,6 +12,6 @@ import { encodeVersion } from './encode_version'; * Helper for encoding a version from a "hit" (hits.hits[#] from _search) or * "doc" (body from GET, update, etc) object */ -export function encodeHitVersion(response: { _seq_no: number; _primary_term: number }) { +export function encodeHitVersion(response: { _seq_no?: number; _primary_term?: number }) { return encodeVersion(response._seq_no, response._primary_term); } diff --git a/src/core/server/saved_objects/version/encode_version.ts b/src/core/server/saved_objects/version/encode_version.ts index fa778ee931e41..9c0b0a7428f38 100644 --- a/src/core/server/saved_objects/version/encode_version.ts +++ b/src/core/server/saved_objects/version/encode_version.ts @@ -13,7 +13,7 @@ import { encodeBase64 } from './base64'; * that can be used in the saved object API in place of numeric * version numbers */ -export function encodeVersion(seqNo: number, primaryTerm: number) { +export function encodeVersion(seqNo?: number, primaryTerm?: number) { if (!Number.isInteger(primaryTerm)) { throw new TypeError('_primary_term from elasticsearch must be an integer'); } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 580315973ce8f..cf1647ef5cec3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -50,6 +50,7 @@ import { DetailedPeerCertificate } from 'tls'; import { Duration } from 'moment'; import { Duration as Duration_2 } from 'moment-timezone'; import { EnvironmentMode } from '@kbn/config'; +import { estypes } from '@elastic/elasticsearch'; import { ExistsParams } from 'elasticsearch'; import { ExplainParams } from 'elasticsearch'; import { FieldStatsParams } from 'elasticsearch'; @@ -1177,6 +1178,12 @@ export type ISavedObjectsExporter = PublicMethodsOf; // @public (undocumented) export type ISavedObjectsImporter = PublicMethodsOf; +// @public (undocumented) +export interface ISavedObjectsPointInTimeFinder { + close: () => Promise; + find: () => AsyncGenerator; +} + // @public export type ISavedObjectsRepository = Pick; @@ -2219,6 +2226,7 @@ export class SavedObjectsClient { checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; + createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) @@ -2321,6 +2329,15 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { version?: string; } +// @public (undocumented) +export interface SavedObjectsCreatePointInTimeFinderDependencies { + // (undocumented) + client: Pick; +} + +// @public (undocumented) +export type SavedObjectsCreatePointInTimeFinderOptions = Omit; + // @public (undocumented) export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOptions { refresh?: boolean; @@ -2491,12 +2508,12 @@ export interface SavedObjectsFindOptions { preference?: string; rootSearchFields?: string[]; search?: string; - searchAfter?: unknown[]; + searchAfter?: estypes.Id[]; searchFields?: string[]; // (undocumented) sortField?: string; // (undocumented) - sortOrder?: string; + sortOrder?: estypes.SortOrder; // (undocumented) type: string | string[]; typeToNamespacesMap?: Map; @@ -2527,7 +2544,7 @@ export interface SavedObjectsFindResponse { // @public (undocumented) export interface SavedObjectsFindResult extends SavedObject { score: number; - sort?: unknown[]; + sort?: string[]; } // @public @@ -2718,11 +2735,12 @@ export interface SavedObjectsIncrementCounterField { } // @public (undocumented) -export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOptions { initialize?: boolean; // (undocumented) migrationVersion?: SavedObjectsMigrationVersion; refresh?: MutatingOperationRefreshSetting; + upsertAttributes?: Attributes; } // @public @@ -2811,17 +2829,18 @@ export class SavedObjectsRepository { checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; + createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // // @internal - static createRepository(migrator: IKibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, includedHiddenTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; + static createRepository(migrator: IKibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, logger: Logger, includedHiddenTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; + incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; diff --git a/src/dev/cli_dev_mode/cli_dev_mode.test.ts b/src/dev/cli_dev_mode/cli_dev_mode.test.ts index 54c49ce21505f..9ace543a8929b 100644 --- a/src/dev/cli_dev_mode/cli_dev_mode.test.ts +++ b/src/dev/cli_dev_mode/cli_dev_mode.test.ts @@ -31,6 +31,9 @@ const { Optimizer } = jest.requireMock('./optimizer'); jest.mock('./dev_server'); const { DevServer } = jest.requireMock('./dev_server'); +jest.mock('@kbn/dev-utils/target/ci_stats_reporter'); +const { CiStatsReporter } = jest.requireMock('@kbn/dev-utils/target/ci_stats_reporter'); + jest.mock('./get_server_watch_paths', () => ({ getServerWatchPaths: jest.fn(() => ({ watchPaths: [''], @@ -208,6 +211,11 @@ describe('#start()/#stop()', () => { run$: devServerRun$, }; }); + CiStatsReporter.fromEnv.mockImplementation(() => { + return { + isEnabled: jest.fn().mockReturnValue(false), + }; + }); }); afterEach(() => { diff --git a/src/dev/cli_dev_mode/cli_dev_mode.ts b/src/dev/cli_dev_mode/cli_dev_mode.ts index 1eed8b14aed4a..f4f95f20daeef 100644 --- a/src/dev/cli_dev_mode/cli_dev_mode.ts +++ b/src/dev/cli_dev_mode/cli_dev_mode.ts @@ -10,7 +10,16 @@ import Path from 'path'; import { REPO_ROOT, CiStatsReporter } from '@kbn/dev-utils'; import * as Rx from 'rxjs'; -import { map, mapTo, filter, take, tap, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { + map, + mapTo, + filter, + take, + tap, + distinctUntilChanged, + switchMap, + concatMap, +} from 'rxjs/operators'; import { CliArgs } from '../../core/server/config'; import { LegacyConfig } from '../../core/server/legacy'; @@ -167,29 +176,10 @@ export class CliDevMode { this.subscription = new Rx.Subscription(); this.startTime = Date.now(); - this.subscription.add( - this.getStarted$() - .pipe( - switchMap(async (success) => { - const reporter = CiStatsReporter.fromEnv(this.log.toolingLog); - await reporter.timings({ - timings: [ - { - group: 'yarn start', - id: 'started', - ms: Date.now() - this.startTime!, - meta: { success }, - }, - ], - }); - }) - ) - .subscribe({ - error: (error) => { - this.log.bad(`[ci-stats/timings] unable to record startup time:`, error.stack); - }, - }) - ); + const reporter = CiStatsReporter.fromEnv(this.log.toolingLog); + if (reporter.isEnabled()) { + this.subscription.add(this.reportTimings(reporter)); + } if (basePathProxy) { const serverReady$ = new Rx.BehaviorSubject(false); @@ -245,6 +235,64 @@ export class CliDevMode { this.subscription.add(this.devServer.run$.subscribe(this.observer('dev server'))); } + private reportTimings(reporter: CiStatsReporter) { + const sub = new Rx.Subscription(); + + sub.add( + this.getStarted$() + .pipe( + concatMap(async (success) => { + await reporter.timings({ + timings: [ + { + group: 'yarn start', + id: 'started', + ms: Date.now() - this.startTime!, + meta: { success }, + }, + ], + }); + }) + ) + .subscribe({ + error: (error) => { + this.log.bad(`[ci-stats/timings] unable to record startup time:`, error.stack); + }, + }) + ); + + sub.add( + this.devServer + .getRestartTime$() + .pipe( + concatMap(async ({ ms }, i) => { + await reporter.timings({ + timings: [ + { + group: 'yarn start', + id: 'dev server restart', + ms, + meta: { + sequence: i + 1, + }, + }, + ], + }); + }) + ) + .subscribe({ + error: (error) => { + this.log.bad( + `[ci-stats/timings] unable to record dev server restart time:`, + error.stack + ); + }, + }) + ); + + return sub; + } + /** * returns an observable that emits once the dev server and optimizer are started, emits * true if they both started successfully, otherwise false diff --git a/src/dev/cli_dev_mode/dev_server.test.ts b/src/dev/cli_dev_mode/dev_server.test.ts index c296c7caca63a..9962a9a285a42 100644 --- a/src/dev/cli_dev_mode/dev_server.test.ts +++ b/src/dev/cli_dev_mode/dev_server.test.ts @@ -15,6 +15,8 @@ import { extendedEnvSerializer } from './test_helpers'; import { DevServer, Options } from './dev_server'; import { TestLog } from './log'; +jest.useFakeTimers('modern'); + class MockProc extends EventEmitter { public readonly signalsSent: string[] = []; @@ -91,6 +93,17 @@ const run = (server: DevServer) => { return subscription; }; +const collect = (stream: Rx.Observable) => { + const events: T[] = []; + const subscription = stream.subscribe({ + next(item) { + events.push(item); + }, + }); + subscriptions.push(subscription); + return events; +}; + afterEach(() => { if (currentProc) { currentProc.removeAllListeners(); @@ -107,6 +120,9 @@ describe('#run$', () => { it('starts the dev server with the right options', () => { run(new DevServer(defaultOptions)).unsubscribe(); + // ensure that FORCE_COLOR is in the env for consistency in snapshot + process.env.FORCE_COLOR = process.env.FORCE_COLOR || 'true'; + expect(execa.node.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -305,7 +321,106 @@ describe('#run$', () => { expect(currentProc.signalsSent).toEqual([]); sigint$.next(); expect(currentProc.signalsSent).toEqual(['SIGINT']); - await new Promise((resolve) => setTimeout(resolve, 1000)); + jest.advanceTimersByTime(100); expect(currentProc.signalsSent).toEqual(['SIGINT', 'SIGKILL']); }); }); + +describe('#getPhase$', () => { + it('emits "starting" when run$ is subscribed then emits "fatal exit" when server exits with code > 0, then starting once watcher fires and "listening" when the server is ready', () => { + const server = new DevServer(defaultOptions); + const events = collect(server.getPhase$()); + + expect(events).toEqual([]); + run(server); + expect(events).toEqual(['starting']); + events.length = 0; + + isProc(currentProc); + currentProc.mockExit(2); + expect(events).toEqual(['fatal exit']); + events.length = 0; + + restart$.next(); + expect(events).toEqual(['starting']); + events.length = 0; + + currentProc.mockListening(); + expect(events).toEqual(['listening']); + }); +}); + +describe('#getRestartTime$()', () => { + it('does not send event if server does not start listening before starting again', () => { + const server = new DevServer(defaultOptions); + const phases = collect(server.getPhase$()); + const events = collect(server.getRestartTime$()); + run(server); + + isProc(currentProc); + restart$.next(); + jest.advanceTimersByTime(1000); + restart$.next(); + jest.advanceTimersByTime(1000); + restart$.next(); + expect(phases).toMatchInlineSnapshot(` + Array [ + "starting", + "starting", + "starting", + "starting", + ] + `); + expect(events).toEqual([]); + }); + + it('reports restart times', () => { + const server = new DevServer(defaultOptions); + const phases = collect(server.getPhase$()); + const events = collect(server.getRestartTime$()); + + run(server); + isProc(currentProc); + + restart$.next(); + currentProc.mockExit(1); + restart$.next(); + restart$.next(); + restart$.next(); + currentProc.mockExit(1); + restart$.next(); + jest.advanceTimersByTime(1234); + currentProc.mockListening(); + restart$.next(); + restart$.next(); + jest.advanceTimersByTime(5678); + currentProc.mockListening(); + + expect(phases).toMatchInlineSnapshot(` + Array [ + "starting", + "starting", + "fatal exit", + "starting", + "starting", + "starting", + "fatal exit", + "starting", + "listening", + "starting", + "starting", + "listening", + ] + `); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "ms": 1234, + }, + Object { + "ms": 5678, + }, + ] + `); + }); +}); diff --git a/src/dev/cli_dev_mode/dev_server.ts b/src/dev/cli_dev_mode/dev_server.ts index a4e32a40665e3..3daf298c82324 100644 --- a/src/dev/cli_dev_mode/dev_server.ts +++ b/src/dev/cli_dev_mode/dev_server.ts @@ -16,6 +16,7 @@ import { share, mergeMap, switchMap, + scan, takeUntil, ignoreElements, } from 'rxjs/operators'; @@ -73,6 +74,32 @@ export class DevServer { return this.phase$.asObservable(); } + /** + * returns an observable of objects describing server start time. + */ + getRestartTime$() { + return this.phase$.pipe( + scan((acc: undefined | { phase: string; time: number }, phase) => { + if (phase === 'starting') { + return { phase, time: Date.now() }; + } + + if (phase === 'listening' && acc?.phase === 'starting') { + return { phase, time: Date.now() - acc.time }; + } + + return undefined; + }, undefined), + mergeMap((desc) => { + if (desc?.phase !== 'listening') { + return []; + } + + return [{ ms: desc.time }]; + }) + ); + } + /** * Run the Kibana server * diff --git a/src/plugins/console/public/application/components/console_menu.tsx b/src/plugins/console/public/application/components/console_menu.tsx index 40e3ce9c44e26..6c5eb8646c58d 100644 --- a/src/plugins/console/public/application/components/console_menu.tsx +++ b/src/plugins/console/public/application/components/console_menu.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; interface Props { getCurl: () => Promise; getDocumentation: () => Promise; - autoIndent: (ev?: React.MouseEvent) => void; + autoIndent: (ev: React.MouseEvent) => void; addNotification?: (opts: { title: string }) => void; } @@ -84,8 +84,7 @@ export class ConsoleMenu extends Component { window.open(documentation, '_blank'); }; - // Using `any` here per this issue: https://github.com/elastic/eui/issues/2265 - autoIndent: any = (event: React.MouseEvent) => { + autoIndent = (event: React.MouseEvent) => { this.closePopover(); this.props.autoIndent(event); }; diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index 161b67500b47c..033bce42177be 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -32,7 +32,7 @@ export type AutocompleteOptions = 'fields' | 'indices' | 'templates'; interface Props { onSaveSettings: (newSettings: DevToolsSettings) => void; onClose: () => void; - refreshAutocompleteSettings: (selectedSettings: any) => void; + refreshAutocompleteSettings: (selectedSettings: DevToolsSettings['autocomplete']) => void; settings: DevToolsSettings; } @@ -233,7 +233,7 @@ export function DevToolsSettingsModal(props: Props) { return rest; })} idToSelectedMap={checkboxIdToSelectedMap} - onChange={(e: any) => { + onChange={(e: unknown) => { onAutocompleteChange(e as AutocompleteOptions); }} /> diff --git a/src/plugins/console/public/application/containers/console_history/console_history.tsx b/src/plugins/console/public/application/containers/console_history/console_history.tsx index 4d5c08705e0d5..7fbef6de80eef 100644 --- a/src/plugins/console/public/application/containers/console_history/console_history.tsx +++ b/src/plugins/console/public/application/containers/console_history/console_history.tsx @@ -53,7 +53,7 @@ export function ConsoleHistory({ close }: Props) { const selectedReq = useRef(null); const describeReq = useMemo(() => { - const _describeReq = (req: any) => { + const _describeReq = (req: { endpoint: string; time: string }) => { const endpoint = req.endpoint; const date = moment(req.time); diff --git a/src/plugins/console/public/application/containers/console_history/history_viewer.tsx b/src/plugins/console/public/application/containers/console_history/history_viewer.tsx index 00a7cf8afa2c0..605f9a254f228 100644 --- a/src/plugins/console/public/application/containers/console_history/history_viewer.tsx +++ b/src/plugins/console/public/application/containers/console_history/history_viewer.tsx @@ -20,7 +20,7 @@ import { applyCurrentSettings } from '../editor/legacy/console_editor/apply_edit interface Props { settings: DevToolsSettings; - req: any | null; + req: { method: string; endpoint: string; data: string; time: string } | null; } export function HistoryViewer({ settings, req }: Props) { diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/apply_editor_settings.ts b/src/plugins/console/public/application/containers/editor/legacy/console_editor/apply_editor_settings.ts index 678ea1c387096..f84999b294742 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/apply_editor_settings.ts +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/apply_editor_settings.ts @@ -14,7 +14,7 @@ export function applyCurrentSettings( editor: CoreEditor | CustomAceEditor, settings: DevToolsSettings ) { - if ((editor as any).setStyles) { + if ((editor as { setStyles?: Function }).setStyles) { (editor as CoreEditor).setStyles({ wrapLines: settings.wrapMode, fontSize: settings.fontSize + 'px', diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx index c4da04dfbf5a6..1732dd9572b90 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.tsx @@ -27,7 +27,7 @@ import { // Mocked functions import { sendRequestToES } from '../../../../hooks/use_send_current_request_to_es/send_request_to_es'; import { getEndpointFromPosition } from '../../../../../lib/autocomplete/get_endpoint_from_position'; - +import type { DevToolsSettings } from '../../../../../services'; import * as consoleMenuActions from '../console_menu_actions'; import { Editor } from './editor'; @@ -40,7 +40,7 @@ describe('Legacy (Ace) Console Editor Component Smoke Test', () => { - + diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index 8b965480d077b..541ad8b0563a4 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -229,7 +229,7 @@ function EditorUI({ initialTextValue }: EditorProps) { getDocumentation={() => { return getDocumentation(editorInstanceRef.current!, docLinkVersion); }} - autoIndent={(event: any) => { + autoIndent={(event) => { autoIndent(editorInstanceRef.current!, event); }} addNotification={({ title }) => notifications.toasts.add({ title })} diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_menu_actions.ts b/src/plugins/console/public/application/containers/editor/legacy/console_menu_actions.ts index 7aa3e96464800..f1bacd62289fb 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_menu_actions.ts +++ b/src/plugins/console/public/application/containers/editor/legacy/console_menu_actions.ts @@ -9,7 +9,7 @@ import { getEndpointFromPosition } from '../../../../lib/autocomplete/get_endpoint_from_position'; import { SenseEditor } from '../../../models/sense_editor'; -export async function autoIndent(editor: SenseEditor, event: Event) { +export async function autoIndent(editor: SenseEditor, event: React.MouseEvent) { event.preventDefault(); await editor.autoIndent(); editor.getCoreEditor().getContainer().focus(); diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx index 7028a479635f4..1282a0b389902 100644 --- a/src/plugins/console/public/application/containers/settings.tsx +++ b/src/plugins/console/public/application/containers/settings.tsx @@ -15,14 +15,20 @@ import { retrieveAutoCompleteInfo } from '../../lib/mappings/mappings'; import { useServicesContext, useEditorActionContext } from '../contexts'; import { DevToolsSettings, Settings as SettingsService } from '../../services'; -const getAutocompleteDiff = (newSettings: DevToolsSettings, prevSettings: DevToolsSettings) => { +const getAutocompleteDiff = ( + newSettings: DevToolsSettings, + prevSettings: DevToolsSettings +): AutocompleteOptions[] => { return Object.keys(newSettings.autocomplete).filter((key) => { // @ts-ignore return prevSettings.autocomplete[key] !== newSettings.autocomplete[key]; - }); + }) as AutocompleteOptions[]; }; -const refreshAutocompleteSettings = (settings: SettingsService, selectedSettings: any) => { +const refreshAutocompleteSettings = ( + settings: SettingsService, + selectedSettings: DevToolsSettings['autocomplete'] +) => { retrieveAutoCompleteInfo(settings, selectedSettings); }; @@ -44,12 +50,12 @@ const fetchAutocompleteSettingsIfNeeded = ( if (isSettingsChanged) { // If the user has changed one of the autocomplete settings, then we'll fetch just the // ones which have changed. - const changedSettings: any = autocompleteDiff.reduce( - (changedSettingsAccum: any, setting: string): any => { - changedSettingsAccum[setting] = newSettings.autocomplete[setting as AutocompleteOptions]; + const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( + (changedSettingsAccum, setting) => { + changedSettingsAccum[setting] = newSettings.autocomplete[setting]; return changedSettingsAccum; }, - {} + {} as DevToolsSettings['autocomplete'] ); retrieveAutoCompleteInfo(settings, changedSettings); } else if (isPollingChanged && newSettings.polling) { @@ -89,7 +95,7 @@ export function Settings({ onClose }: Props) { + refreshAutocompleteSettings={(selectedSettings) => refreshAutocompleteSettings(settings, selectedSettings) } settings={settings.toJSON()} diff --git a/src/plugins/console/public/application/contexts/editor_context/editor_context.tsx b/src/plugins/console/public/application/contexts/editor_context/editor_context.tsx index 32e5216a85d7e..d4f809b5fbfb3 100644 --- a/src/plugins/console/public/application/contexts/editor_context/editor_context.tsx +++ b/src/plugins/console/public/application/contexts/editor_context/editor_context.tsx @@ -11,11 +11,11 @@ import * as editor from '../../stores/editor'; import { DevToolsSettings } from '../../../services'; import { createUseContext } from '../create_use_context'; -const EditorReadContext = createContext(null as any); -const EditorActionContext = createContext>(null as any); +const EditorReadContext = createContext(editor.initialValue); +const EditorActionContext = createContext>(() => {}); export interface EditorContextArgs { - children: any; + children: JSX.Element; settings: DevToolsSettings; } @@ -25,7 +25,7 @@ export function EditorContextProvider({ children, settings }: EditorContextArgs) settings, })); return ( - + {children} ); diff --git a/src/plugins/console/public/application/contexts/request_context.tsx b/src/plugins/console/public/application/contexts/request_context.tsx index 38ac5c7163add..96ba1f69212b4 100644 --- a/src/plugins/console/public/application/contexts/request_context.tsx +++ b/src/plugins/console/public/application/contexts/request_context.tsx @@ -10,8 +10,8 @@ import React, { createContext, useReducer, Dispatch } from 'react'; import { createUseContext } from './create_use_context'; import * as store from '../stores/request'; -const RequestReadContext = createContext(null as any); -const RequestActionContext = createContext>(null as any); +const RequestReadContext = createContext(store.initialValue); +const RequestActionContext = createContext>(() => {}); export function RequestContextProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(store.reducer, store.initialValue); diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 6c67aa37727b2..c4ac8ca25378b 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -9,6 +9,7 @@ import { notificationServiceMock } from '../../../../../core/public/mocks'; import { httpServiceMock } from '../../../../../core/public/mocks'; +import type { ObjectStorageClient } from '../../../common/types'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; import { StorageMock } from '../../services/storage.mock'; @@ -18,7 +19,7 @@ import { ContextValue } from './services_context'; export const serviceContextMock = { create: (): ContextValue => { - const storage = new StorageMock({} as any, 'test'); + const storage = new StorageMock(({} as unknown) as Storage, 'test'); const http = httpServiceMock.createSetupContract(); const api = createApi({ http }); const esHostService = createEsHostService({ api }); @@ -31,7 +32,7 @@ export const serviceContextMock = { settings: new SettingsMock(storage), history: new HistoryMock(storage), notifications: notificationServiceMock.createSetupContract(), - objectStorageClient: {} as any, + objectStorageClient: ({} as unknown) as ObjectStorageClient, }, docLinkVersion: 'NA', }; diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index 58587bf3030e2..53c021d4d0982 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -30,10 +30,10 @@ export interface ContextValue { interface ContextProps { value: ContextValue; - children: any; + children: JSX.Element; } -const ServicesContext = createContext(null as any); +const ServicesContext = createContext(null); export function ServicesContextProvider({ children, value }: ContextProps) { useEffect(() => { @@ -46,8 +46,8 @@ export function ServicesContextProvider({ children, value }: ContextProps) { export const useServicesContext = () => { const context = useContext(ServicesContext); - if (context === undefined) { + if (context == null) { throw new Error('useServicesContext must be used inside the ServicesContextProvider.'); } - return context; + return context!; }; diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts index f8537f9d0b3c4..85c9cf6b9f014 100644 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts +++ b/src/plugins/console/public/application/hooks/use_restore_request_from_history/restore_request_from_history.ts @@ -7,12 +7,10 @@ */ import RowParser from '../../../lib/row_parser'; +import { ESRequest } from '../../../types'; import { SenseEditor } from '../../models/sense_editor'; -/** - * This function is considered legacy and should not be changed or updated before we have editor - * interfaces in place (it's using a customized version of Ace directly). - */ -export function restoreRequestFromHistory(editor: SenseEditor, req: any) { + +export function restoreRequestFromHistory(editor: SenseEditor, req: ESRequest) { const coreEditor = editor.getCoreEditor(); let pos = coreEditor.getCurrentPosition(); let prefix = ''; diff --git a/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts b/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts index 310d6c67b90bc..7c140e2b18975 100644 --- a/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts +++ b/src/plugins/console/public/application/hooks/use_restore_request_from_history/use_restore_request_from_history.ts @@ -8,10 +8,11 @@ import { useCallback } from 'react'; import { instance as registry } from '../../contexts/editor_context/editor_registry'; +import { ESRequest } from '../../../types'; import { restoreRequestFromHistory } from './restore_request_from_history'; export const useRestoreRequestFromHistory = () => { - return useCallback((req: any) => { + return useCallback((req: ESRequest) => { const editor = registry.getInputEditor(); restoreRequestFromHistory(editor, req); }, []); diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts index aeaa2f76816e4..a86cfd8890a5b 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/send_request_to_es.ts @@ -8,19 +8,14 @@ import { extractWarningMessages } from '../../../lib/utils'; import { XJson } from '../../../../../es_ui_shared/public'; -const { collapseLiteralStrings } = XJson; // @ts-ignore import * as es from '../../../lib/es/es'; import { BaseResponseType } from '../../../types'; -export interface EsRequestArgs { - requests: any; -} +const { collapseLiteralStrings } = XJson; -export interface ESRequestObject { - path: string; - data: any; - method: string; +export interface EsRequestArgs { + requests: Array<{ url: string; method: string; data: string[] }>; } export interface ESResponseObject { @@ -32,7 +27,7 @@ export interface ESResponseObject { } export interface ESRequestResult { - request: ESRequestObject; + request: { data: string; method: string; path: string }; response: ESResponseObject; } @@ -61,7 +56,7 @@ export function sendRequestToES(args: EsRequestArgs): Promise resolve(results); return; } - const req = requests.shift(); + const req = requests.shift()!; const esPath = req.url; const esMethod = req.method; let esData = collapseLiteralStrings(req.data.join('\n')); @@ -71,7 +66,7 @@ export function sendRequestToES(args: EsRequestArgs): Promise const startTime = Date.now(); es.send(esMethod, esPath, esData).always( - (dataOrjqXHR: any, textStatus: string, jqXhrORerrorThrown: any) => { + (dataOrjqXHR, textStatus: string, jqXhrORerrorThrown) => { if (reqId !== CURRENT_REQ_ID) { return; } diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/track.ts b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/track.ts index c9b8cdec66a96..f5deefd627187 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/track.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/track.ts @@ -10,7 +10,11 @@ import { SenseEditor } from '../../models/sense_editor'; import { getEndpointFromPosition } from '../../../lib/autocomplete/get_endpoint_from_position'; import { MetricsTracker } from '../../../types'; -export const track = (requests: any[], editor: SenseEditor, trackUiMetric: MetricsTracker) => { +export const track = ( + requests: Array<{ method: string }>, + editor: SenseEditor, + trackUiMetric: MetricsTracker +) => { const coreEditor = editor.getCoreEditor(); // `getEndpointFromPosition` gets values from the server-side generated JSON files which // are a combination of JS, automatically generated JSON and manual overrides. That means diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx index e1fc323cd2d9d..63b5788316956 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_es/use_send_current_request_to_es.test.tsx @@ -26,8 +26,8 @@ import { useSendCurrentRequestToES } from './use_send_current_request_to_es'; describe('useSendCurrentRequestToES', () => { let mockContextValue: ContextValue; - let dispatch: (...args: any[]) => void; - const contexts = ({ children }: { children?: any }) => ( + let dispatch: (...args: unknown[]) => void; + const contexts = ({ children }: { children: JSX.Element }) => ( {children} ); diff --git a/src/plugins/console/public/application/hooks/use_set_input_editor.ts b/src/plugins/console/public/application/hooks/use_set_input_editor.ts index 2c6dc101bee0e..c01dbbb0d2798 100644 --- a/src/plugins/console/public/application/hooks/use_set_input_editor.ts +++ b/src/plugins/console/public/application/hooks/use_set_input_editor.ts @@ -9,12 +9,13 @@ import { useCallback } from 'react'; import { useEditorActionContext } from '../contexts/editor_context'; import { instance as registry } from '../contexts/editor_context/editor_registry'; +import { SenseEditor } from '../models'; export const useSetInputEditor = () => { const dispatch = useEditorActionContext(); return useCallback( - (editor: any) => { + (editor: SenseEditor) => { dispatch({ type: 'setInputEditor', payload: editor }); registry.setInputEditor(editor); }, diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 1237348af215c..0b41095f8cc19 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { HttpSetup, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, NotificationsSetup, I18nStart } from 'src/core/public'; import { ServicesContextProvider, EditorContextProvider, RequestContextProvider } from './contexts'; import { Main } from './containers'; import { createStorage, createHistory, createSettings } from '../services'; @@ -20,7 +20,7 @@ import { createApi, createEsHostService } from './lib'; export interface BootDependencies { http: HttpSetup; docLinkVersion: string; - I18nContext: any; + I18nContext: I18nStart['Context']; notifications: NotificationsSetup; usageCollection?: UsageCollectionSetup; element: HTMLElement; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts b/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts index 43da86773d294..dc63f0dcd480c 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/create_readonly.ts @@ -13,7 +13,7 @@ import * as OutputMode from './mode/output'; import smartResize from './smart_resize'; export interface CustomAceEditor extends ace.Editor { - update: (text: string, mode?: any, cb?: () => void) => void; + update: (text: string, mode?: unknown, cb?: () => void) => void; append: (text: string, foldPrevious?: boolean, cb?: () => void) => void; } @@ -28,9 +28,9 @@ export function createReadOnlyAceEditor(element: HTMLElement): CustomAceEditor { output.$blockScrolling = Infinity; output.resize = smartResize(output); - output.update = (val: string, mode?: any, cb?: () => void) => { + output.update = (val: string, mode?: unknown, cb?: () => void) => { if (typeof mode === 'function') { - cb = mode; + cb = mode as () => void; mode = void 0; } @@ -65,7 +65,7 @@ export function createReadOnlyAceEditor(element: HTMLElement): CustomAceEditor { (function setupSession(session) { session.setMode('ace/mode/text'); - (session as any).setFoldStyle('markbeginend'); + ((session as unknown) as { setFoldStyle: (v: string) => void }).setFoldStyle('markbeginend'); session.setTabSize(2); session.setUseWrapMode(true); })(output.getSession()); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts index c39d4549de0b6..0ee15f7a559ae 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.test.mocks.ts @@ -13,7 +13,7 @@ jest.mock('./mode/worker', () => { // @ts-ignore window.Worker = function () { this.postMessage = () => {}; - (this as any).terminate = () => {}; + ((this as unknown) as { terminate: () => void }).terminate = () => {}; }; // @ts-ignore diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index eab5ac16d17db..fa118532aa52d 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -31,8 +31,8 @@ const rangeToAceRange = ({ start, end }: Range) => new _AceRange(start.lineNumber - 1, start.column - 1, end.lineNumber - 1, end.column - 1); export class LegacyCoreEditor implements CoreEditor { - private _aceOnPaste: any; - $actions: any; + private _aceOnPaste: Function; + $actions: JQuery; resize: () => void; constructor(private readonly editor: IAceEditor, actions: HTMLElement) { @@ -41,7 +41,9 @@ export class LegacyCoreEditor implements CoreEditor { const session = this.editor.getSession(); session.setMode(new InputMode.Mode()); - (session as any).setFoldStyle('markbeginend'); + ((session as unknown) as { setFoldStyle: (style: string) => void }).setFoldStyle( + 'markbeginend' + ); session.setTabSize(2); session.setUseWrapMode(true); @@ -72,7 +74,7 @@ export class LegacyCoreEditor implements CoreEditor { // torn down, e.g. by closing the History tab, and we don't need to do anything further. if (session.bgTokenizer) { // Wait until the bgTokenizer is done running before executing the callback. - if ((session.bgTokenizer as any).running) { + if (((session.bgTokenizer as unknown) as { running: boolean }).running) { setTimeout(check, checkInterval); } else { resolve(); @@ -197,7 +199,7 @@ export class LegacyCoreEditor implements CoreEditor { .addMarker(rangeToAceRange(range), 'ace_snippet-marker', 'fullLine', false); } - removeMarker(ref: any) { + removeMarker(ref: number) { this.editor.getSession().removeMarker(ref); } @@ -222,8 +224,10 @@ export class LegacyCoreEditor implements CoreEditor { } isCompleterActive() { - // Secrets of the arcane here. - return Boolean((this.editor as any).completer && (this.editor as any).completer.activated); + return Boolean( + ((this.editor as unknown) as { completer: { activated: unknown } }).completer && + ((this.editor as unknown) as { completer: { activated: unknown } }).completer.activated + ); } private forceRetokenize() { @@ -250,7 +254,7 @@ export class LegacyCoreEditor implements CoreEditor { this._aceOnPaste.call(this.editor, text); } - private setActionsBar = (value?: any, topOrBottom: 'top' | 'bottom' = 'top') => { + private setActionsBar = (value: number | null, topOrBottom: 'top' | 'bottom' = 'top') => { if (value === null) { this.$actions.css('visibility', 'hidden'); } else { @@ -271,7 +275,7 @@ export class LegacyCoreEditor implements CoreEditor { }; private hideActionsBar = () => { - this.setActionsBar(); + this.setActionsBar(null); }; execCommand(cmd: string) { @@ -295,7 +299,7 @@ export class LegacyCoreEditor implements CoreEditor { }); } - legacyUpdateUI(range: any) { + legacyUpdateUI(range: Range) { if (!this.$actions) { return; } @@ -360,14 +364,19 @@ export class LegacyCoreEditor implements CoreEditor { ace.define( 'ace/autocomplete/text_completer', ['require', 'exports', 'module'], - function (require: any, exports: any) { - exports.getCompletions = function ( - innerEditor: any, - session: any, - pos: any, - prefix: any, - callback: any - ) { + function ( + require: unknown, + exports: { + getCompletions: ( + innerEditor: unknown, + session: unknown, + pos: unknown, + prefix: unknown, + callback: (e: null | Error, values: string[]) => void + ) => void; + } + ) { + exports.getCompletions = function (innerEditor, session, pos, prefix, callback) { callback(null, []); }; } @@ -387,7 +396,7 @@ export class LegacyCoreEditor implements CoreEditor { DO_NOT_USE_2: IAceEditSession, pos: { row: number; column: number }, prefix: string, - callback: (...args: any[]) => void + callback: (...args: unknown[]) => void ) => { const position: Position = { lineNumber: pos.row + 1, diff --git a/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts b/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts index fdbaedce09187..83d7cd15e60eb 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/smart_resize.ts @@ -7,9 +7,10 @@ */ import { get, throttle } from 'lodash'; +import type { Editor } from 'brace'; // eslint-disable-next-line import/no-default-export -export default function (editor: any) { +export default function (editor: Editor) { const resize = editor.resize; const throttledResize = throttle(() => { diff --git a/src/plugins/console/public/application/models/sense_editor/curl.ts b/src/plugins/console/public/application/models/sense_editor/curl.ts index 299ccd0a1f6a6..74cbebf051d03 100644 --- a/src/plugins/console/public/application/models/sense_editor/curl.ts +++ b/src/plugins/console/public/application/models/sense_editor/curl.ts @@ -25,7 +25,7 @@ export function detectCURL(text: string) { export function parseCURL(text: string) { let state = 'NONE'; const out = []; - let body: any[] = []; + let body: string[] = []; let line = ''; const lines = text.trim().split('\n'); let matches; @@ -62,7 +62,7 @@ export function parseCURL(text: string) { } function unescapeLastBodyEl() { - const str = body.pop().replace(/\\([\\"'])/g, '$1'); + const str = body.pop()!.replace(/\\([\\"'])/g, '$1'); body.push(str); } diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index d6dd74f0fefe3..0f65d3f1e33e2 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -7,8 +7,10 @@ */ import _ from 'lodash'; -import RowParser from '../../../lib/row_parser'; + import { XJson } from '../../../../../es_ui_shared/public'; + +import RowParser from '../../../lib/row_parser'; import * as utils from '../../../lib/utils'; // @ts-ignore @@ -16,22 +18,20 @@ import * as es from '../../../lib/es/es'; import { CoreEditor, Position, Range } from '../../../types'; import { createTokenIterator } from '../../factories'; - -import Autocomplete from '../../../lib/autocomplete/autocomplete'; +import createAutocompleter from '../../../lib/autocomplete/autocomplete'; const { collapseLiteralStrings } = XJson; export class SenseEditor { - currentReqRange: (Range & { markerRef: any }) | null; - parser: any; + currentReqRange: (Range & { markerRef: unknown }) | null; + parser: RowParser; - // @ts-ignore - private readonly autocomplete: any; + private readonly autocomplete: ReturnType; constructor(private readonly coreEditor: CoreEditor) { this.currentReqRange = null; this.parser = new RowParser(this.coreEditor); - this.autocomplete = new (Autocomplete as any)({ + this.autocomplete = createAutocompleter({ coreEditor, parser: this.parser, }); @@ -114,7 +114,10 @@ export class SenseEditor { return this.coreEditor.setValue(data, reTokenizeAll); }; - replaceRequestRange = (newRequest: any, requestRange: Range) => { + replaceRequestRange = ( + newRequest: { method: string; url: string; data: string | string[] }, + requestRange: Range + ) => { const text = utils.textFromRequest(newRequest); if (requestRange) { this.coreEditor.replaceRange(requestRange, text); @@ -207,12 +210,12 @@ export class SenseEditor { const request: { method: string; data: string[]; - url: string | null; + url: string; range: Range; } = { method: '', data: [], - url: null, + url: '', range, }; @@ -284,7 +287,7 @@ export class SenseEditor { return []; } - const requests: any = []; + const requests: unknown[] = []; let rangeStartCursor = expandedRange.start.lineNumber; const endLineNumber = expandedRange.end.lineNumber; diff --git a/src/plugins/console/public/application/stores/editor.ts b/src/plugins/console/public/application/stores/editor.ts index 1de4712d9640f..3fdb0c3fd3422 100644 --- a/src/plugins/console/public/application/stores/editor.ts +++ b/src/plugins/console/public/application/stores/editor.ts @@ -9,8 +9,9 @@ import { Reducer } from 'react'; import { produce } from 'immer'; import { identity } from 'fp-ts/lib/function'; -import { DevToolsSettings } from '../../services'; +import { DevToolsSettings, DEFAULT_SETTINGS } from '../../services'; import { TextObject } from '../../../common/text_object'; +import { SenseEditor } from '../models'; export interface Store { ready: boolean; @@ -21,15 +22,15 @@ export interface Store { export const initialValue: Store = produce( { ready: false, - settings: null as any, + settings: DEFAULT_SETTINGS, currentTextObject: null, }, identity ); export type Action = - | { type: 'setInputEditor'; payload: any } - | { type: 'setCurrentTextObject'; payload: any } + | { type: 'setInputEditor'; payload: SenseEditor } + | { type: 'setCurrentTextObject'; payload: TextObject } | { type: 'updateSettings'; payload: DevToolsSettings }; export const reducer: Reducer = (state, action) => diff --git a/src/plugins/console/public/lib/ace_token_provider/token_provider.ts b/src/plugins/console/public/lib/ace_token_provider/token_provider.ts index ac518d43ddf25..692528fb8bced 100644 --- a/src/plugins/console/public/lib/ace_token_provider/token_provider.ts +++ b/src/plugins/console/public/lib/ace_token_provider/token_provider.ts @@ -63,7 +63,7 @@ export class AceTokensProvider implements TokensProvider { return null; } - const tokens: TokenInfo[] = this.session.getTokens(lineNumber - 1) as any; + const tokens = (this.session.getTokens(lineNumber - 1) as unknown) as TokenInfo[]; if (!tokens || !tokens.length) { // We are inside of the document but have no tokens for this line. Return an empty // array to represent this empty line. @@ -74,7 +74,7 @@ export class AceTokensProvider implements TokensProvider { } getTokenAt(pos: Position): Token | null { - const tokens: TokenInfo[] = this.session.getTokens(pos.lineNumber - 1) as any; + const tokens = (this.session.getTokens(pos.lineNumber - 1) as unknown) as TokenInfo[]; if (tokens) { return extractTokenFromAceTokenRow(pos.lineNumber, pos.column, tokens); } diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index e46b5cda3c3a9..d89a9f3d2e5e2 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -18,19 +18,21 @@ import { // @ts-ignore } from '../kb/kb'; +import { createTokenIterator } from '../../application/factories'; +import { Position, Token, Range, CoreEditor } from '../../types'; +import type RowParser from '../row_parser'; + import * as utils from '../utils'; // @ts-ignore import { populateContext } from './engine'; +import { AutoCompleteContext, ResultTerm } from './types'; // @ts-ignore import { URL_PATH_END_MARKER } from './components/index'; -import { createTokenIterator } from '../../application/factories'; -import { Position, Token, Range, CoreEditor } from '../../types'; +let lastEvaluatedToken: Token | null = null; -let lastEvaluatedToken: any = null; - -function isUrlParamsToken(token: any) { +function isUrlParamsToken(token: { type: string } | null) { switch ((token || {}).type) { case 'url.param': case 'url.equal': @@ -54,7 +56,7 @@ function isUrlParamsToken(token: any) { export function getCurrentMethodAndTokenPaths( editor: CoreEditor, pos: Position, - parser: any, + parser: RowParser, forceEndOfUrl?: boolean /* Flag for indicating whether we want to avoid early escape optimization. */ ) { const tokenIter = createTokenIterator({ @@ -62,8 +64,8 @@ export function getCurrentMethodAndTokenPaths( position: pos, }); const startPos = pos; - let bodyTokenPath: any = []; - const ret: any = {}; + let bodyTokenPath: string[] | null = []; + const ret: AutoCompleteContext = {}; const STATES = { looking_for_key: 0, // looking for a key but without jumping over anything but white space and colon. @@ -210,7 +212,12 @@ export function getCurrentMethodAndTokenPaths( ret.urlParamsTokenPath = null; ret.requestStartRow = tokenIter.getCurrentPosition().lineNumber; - let curUrlPart: any; + let curUrlPart: + | null + | string + | Array> + | undefined + | Record; while (t && isUrlParamsToken(t)) { switch (t.type) { @@ -240,7 +247,7 @@ export function getCurrentMethodAndTokenPaths( if (!ret.urlParamsTokenPath) { ret.urlParamsTokenPath = []; } - ret.urlParamsTokenPath.unshift(curUrlPart || {}); + ret.urlParamsTokenPath.unshift((curUrlPart as Record) || {}); curUrlPart = null; break; } @@ -268,7 +275,7 @@ export function getCurrentMethodAndTokenPaths( break; case 'url.slash': if (curUrlPart) { - ret.urlTokenPath.unshift(curUrlPart); + ret.urlTokenPath.unshift(curUrlPart as string); curUrlPart = null; } break; @@ -277,7 +284,7 @@ export function getCurrentMethodAndTokenPaths( } if (curUrlPart) { - ret.urlTokenPath.unshift(curUrlPart); + ret.urlTokenPath.unshift(curUrlPart as string); } if (!ret.bodyTokenPath && !ret.urlParamsTokenPath) { @@ -297,9 +304,15 @@ export function getCurrentMethodAndTokenPaths( } // eslint-disable-next-line -export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEditor; parser: any }) { +export default function ({ + coreEditor: editor, + parser, +}: { + coreEditor: CoreEditor; + parser: RowParser; +}) { function isUrlPathToken(token: Token | null) { - switch ((token || ({} as any)).type) { + switch ((token || ({} as Token)).type) { case 'url.slash': case 'url.comma': case 'url.part': @@ -309,8 +322,12 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } } - function addMetaToTermsList(list: any, meta: any, template?: string) { - return _.map(list, function (t: any) { + function addMetaToTermsList( + list: unknown[], + meta: unknown, + template?: string + ): Array<{ meta: unknown; template: unknown; name?: string }> { + return _.map(list, function (t) { if (typeof t !== 'object') { t = { name: t }; } @@ -318,8 +335,13 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito }); } - function applyTerm(term: any) { - const context = term.context; + function applyTerm(term: { + value?: string; + context?: AutoCompleteContext; + template?: { __raw: boolean; value: string }; + insertValue?: string; + }) { + const context = term.context!; // make sure we get up to date replacement info. addReplacementInfoToContext(context, editor.getCurrentPosition(), term.insertValue); @@ -346,7 +368,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } else { indentedTemplateLines = utils.jsonToString(term.template, true).split('\n'); } - let currentIndentation = editor.getLineValue(context.rangeToReplace.start.lineNumber); + let currentIndentation = editor.getLineValue(context.rangeToReplace!.start.lineNumber); currentIndentation = currentIndentation.match(/^\s*/)![0]; for ( let i = 1; @@ -374,8 +396,8 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito // disable listening to the changes we are making. editor.off('changeSelection', editorChangeListener); - if (context.rangeToReplace.start.column !== context.rangeToReplace.end.column) { - editor.replace(context.rangeToReplace, valueToInsert); + if (context.rangeToReplace!.start.column !== context.rangeToReplace!.end.column) { + editor.replace(context.rangeToReplace!, valueToInsert); } else { editor.insert(valueToInsert); } @@ -384,12 +406,12 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito // go back to see whether we have one of ( : { & [ do not require a comma. All the rest do. let newPos = { - lineNumber: context.rangeToReplace.start.lineNumber, + lineNumber: context.rangeToReplace!.start.lineNumber, column: - context.rangeToReplace.start.column + + context.rangeToReplace!.start.column + termAsString.length + - context.prefixToAdd.length + - (templateInserted ? 0 : context.suffixToAdd.length), + context.prefixToAdd!.length + + (templateInserted ? 0 : context.suffixToAdd!.length), }; const tokenIter = createTokenIterator({ @@ -406,7 +428,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito break; case 'punctuation.colon': nonEmptyToken = parser.nextNonEmptyToken(tokenIter); - if ((nonEmptyToken || ({} as any)).type === 'paren.lparen') { + if ((nonEmptyToken || ({} as Token)).type === 'paren.lparen') { nonEmptyToken = parser.nextNonEmptyToken(tokenIter); newPos = tokenIter.getCurrentPosition(); if (nonEmptyToken && nonEmptyToken.value.indexOf('"') === 0) { @@ -429,7 +451,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito function getAutoCompleteContext(ctxEditor: CoreEditor, pos: Position) { // deduces all the parameters need to position and insert the auto complete - const context: any = { + const context: AutoCompleteContext = { autoCompleteSet: null, // instructions for what can be here endpoint: null, urlPath: null, @@ -501,7 +523,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito case 'whitespace': t = parser.prevNonEmptyToken(tokenIter); - switch ((t || ({} as any)).type) { + switch ((t || ({} as Token)).type) { case 'method': // we moved one back return 'path'; @@ -552,7 +574,11 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito return null; } - function addReplacementInfoToContext(context: any, pos: Position, replacingTerm?: any) { + function addReplacementInfoToContext( + context: AutoCompleteContext, + pos: Position, + replacingTerm?: unknown + ) { // extract the initial value, rangeToReplace & textBoxPosition // Scenarios for current token: @@ -605,7 +631,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito default: if (replacingTerm && context.updatedForToken.value === replacingTerm) { context.rangeToReplace = { - start: { lineNumber: pos.lineNumber, column: anchorToken.column }, + start: { lineNumber: pos.lineNumber, column: anchorToken.position.column }, end: { lineNumber: pos.lineNumber, column: @@ -645,7 +671,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } } - function addBodyPrefixSuffixToContext(context: any) { + function addBodyPrefixSuffixToContext(context: AutoCompleteContext) { // Figure out what happens next to the token to see whether it needs trailing commas etc. // Templates will be used if not destroying existing structure. @@ -680,9 +706,9 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } context.addTemplate = true; // extend range to replace to include all up to token - context.rangeToReplace.end.lineNumber = tokenIter.getCurrentTokenLineNumber(); - context.rangeToReplace.end.column = - tokenIter.getCurrentTokenColumn() + nonEmptyToken.value.length; + context.rangeToReplace!.end.lineNumber = tokenIter.getCurrentTokenLineNumber() as number; + context.rangeToReplace!.end.column = + (tokenIter.getCurrentTokenColumn() as number) + nonEmptyToken.value.length; // move one more time to check if we need a trailing comma nonEmptyToken = parser.nextNonEmptyToken(tokenIter); @@ -711,11 +737,11 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito insertingRelativeToToken = 0; } else { const pos = editor.getCurrentPosition(); - if (pos.column === context.updatedForToken.position.column) { + if (pos.column === context.updatedForToken!.position.column) { insertingRelativeToToken = -1; } else if ( pos.column < - context.updatedForToken.position.column + context.updatedForToken.value.length + context.updatedForToken!.position.column + context.updatedForToken!.value.length ) { insertingRelativeToToken = 0; } else { @@ -743,12 +769,12 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito return context; } - function addUrlParamsPrefixSuffixToContext(context: any) { + function addUrlParamsPrefixSuffixToContext(context: AutoCompleteContext) { context.prefixToAdd = ''; context.suffixToAdd = ''; } - function addMethodPrefixSuffixToContext(context: any) { + function addMethodPrefixSuffixToContext(context: AutoCompleteContext) { context.prefixToAdd = ''; context.suffixToAdd = ''; const tokenIter = createTokenIterator({ editor, position: editor.getCurrentPosition() }); @@ -761,12 +787,12 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } } - function addPathPrefixSuffixToContext(context: any) { + function addPathPrefixSuffixToContext(context: AutoCompleteContext) { context.prefixToAdd = ''; context.suffixToAdd = ''; } - function addMethodAutoCompleteSetToContext(context: any) { + function addMethodAutoCompleteSetToContext(context: AutoCompleteContext) { context.autoCompleteSet = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'].map((m, i) => ({ name: m, score: -i, @@ -774,7 +800,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito })); } - function addPathAutoCompleteSetToContext(context: any, pos: Position) { + function addPathAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); context.method = ret.method; context.token = ret.token; @@ -783,10 +809,10 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito const components = getTopLevelUrlCompleteComponents(context.method); populateContext(ret.urlTokenPath, context, editor, true, components); - context.autoCompleteSet = addMetaToTermsList(context.autoCompleteSet, 'endpoint'); + context.autoCompleteSet = addMetaToTermsList(context.autoCompleteSet!, 'endpoint'); } - function addUrlParamsAutoCompleteSetToContext(context: any, pos: Position) { + function addUrlParamsAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); context.method = ret.method; context.otherTokenValues = ret.otherTokenValues; @@ -813,7 +839,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito // zero length tokenPath is true return context; } - let tokenPath: any[] = []; + let tokenPath: string[] = []; const currentParam = ret.urlParamsTokenPath.pop(); if (currentParam) { tokenPath = Object.keys(currentParam); // single key object @@ -830,7 +856,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito return context; } - function addBodyAutoCompleteSetToContext(context: any, pos: Position) { + function addBodyAutoCompleteSetToContext(context: AutoCompleteContext, pos: Position) { const ret = getCurrentMethodAndTokenPaths(editor, pos, parser); context.method = ret.method; context.otherTokenValues = ret.otherTokenValues; @@ -859,7 +885,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito // needed for scope linking + global term resolving context.endpointComponentResolver = getEndpointBodyCompleteComponents; context.globalComponentResolver = getGlobalAutocompleteComponents; - let components; + let components: unknown; if (context.endpoint) { components = context.endpoint.bodyAutocompleteRootComponents; } else { @@ -935,15 +961,19 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } } - function getCompletions(position: Position, prefix: string, callback: (...args: any[]) => void) { + function getCompletions( + position: Position, + prefix: string, + callback: (e: Error | null, result: ResultTerm[] | null) => void + ) { try { const context = getAutoCompleteContext(editor, position); if (!context) { callback(null, []); } else { const terms = _.map( - context.autoCompleteSet.filter((term: any) => Boolean(term) && term.name != null), - function (term: any) { + context.autoCompleteSet!.filter((term) => Boolean(term) && term.name != null), + function (term) { if (typeof term !== 'object') { term = { name: term, @@ -951,7 +981,13 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } else { term = _.clone(term); } - const defaults: any = { + const defaults: { + value?: string; + meta: string; + score: number; + context: AutoCompleteContext; + completer?: { insertMatch: (v: unknown) => void }; + } = { value: term.name, meta: 'API', score: 0, @@ -969,7 +1005,10 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito } ); - terms.sort(function (t1: any, t2: any) { + terms.sort(function ( + t1: { score: number; name?: string }, + t2: { score: number; name?: string } + ) { /* score sorts from high to low */ if (t1.score > t2.score) { return -1; @@ -978,7 +1017,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito return 1; } /* names sort from low to high */ - if (t1.name < t2.name) { + if (t1.name! < t2.name!) { return -1; } if (t1.name === t2.name) { @@ -989,7 +1028,7 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito callback( null, - _.map(terms, function (t: any, i: any) { + _.map(terms, function (t, i) { t.insertValue = t.insertValue || t.value; t.value = '' + t.value; // normalize to strings t.score = -i; @@ -1010,8 +1049,13 @@ export default function ({ coreEditor: editor, parser }: { coreEditor: CoreEdito getCompletions, // TODO: This needs to be cleaned up _test: { - getCompletions: (_editor: any, _editSession: any, pos: any, prefix: any, callback: any) => - getCompletions(pos, prefix, callback), + getCompletions: ( + _editor: unknown, + _editSession: unknown, + pos: Position, + prefix: string, + callback: (e: Error | null, result: ResultTerm[] | null) => void + ) => getCompletions(pos, prefix, callback), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/components/full_request_component.ts b/src/plugins/console/public/lib/autocomplete/components/full_request_component.ts index 935e3622bde04..64aefa7b4a121 100644 --- a/src/plugins/console/public/lib/autocomplete/components/full_request_component.ts +++ b/src/plugins/console/public/lib/autocomplete/components/full_request_component.ts @@ -11,7 +11,7 @@ import { ConstantComponent } from './constant_component'; export class FullRequestComponent extends ConstantComponent { private readonly name: string; - constructor(name: string, parent: any, private readonly template: string) { + constructor(name: string, parent: unknown, private readonly template: string) { super(name, parent); this.name = name; } diff --git a/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts b/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts index 849123bc68a71..fe24492df0e7b 100644 --- a/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts +++ b/src/plugins/console/public/lib/autocomplete/get_endpoint_from_position.ts @@ -8,13 +8,14 @@ import { CoreEditor, Position } from '../../types'; import { getCurrentMethodAndTokenPaths } from './autocomplete'; +import type RowParser from '../row_parser'; // @ts-ignore import { getTopLevelUrlCompleteComponents } from '../kb/kb'; // @ts-ignore import { populateContext } from './engine'; -export function getEndpointFromPosition(editor: CoreEditor, pos: Position, parser: any) { +export function getEndpointFromPosition(editor: CoreEditor, pos: Position, parser: RowParser) { const lineValue = editor.getLineValue(pos.lineNumber); const context = { ...getCurrentMethodAndTokenPaths( diff --git a/src/plugins/console/public/lib/autocomplete/types.ts b/src/plugins/console/public/lib/autocomplete/types.ts new file mode 100644 index 0000000000000..33c543f43be9e --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/types.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreEditor, Range, Token } from '../../types'; + +export interface ResultTerm { + context?: AutoCompleteContext; + insertValue?: string; + name?: string; + value?: string; +} + +export interface AutoCompleteContext { + autoCompleteSet?: null | ResultTerm[]; + endpoint?: null | { + paramsAutocomplete: { + getTopLevelComponents: (method?: string | null) => unknown; + }; + bodyAutocompleteRootComponents: unknown; + id?: string; + documentation?: string; + }; + urlPath?: null | unknown; + urlParamsTokenPath?: Array> | null; + method?: string | null; + token?: Token; + activeScheme?: unknown; + replacingToken?: boolean; + rangeToReplace?: Range; + autoCompleteType?: null | string; + editor?: CoreEditor; + + /** + * The tokenized user input that prompted the current autocomplete at the cursor. This can be out of sync with + * the input that is currently being displayed in the editor. + */ + createdWithToken?: Token | null; + + /** + * The tokenized user input that is currently being displayed at the cursor in the editor when the user accepted + * the autocomplete suggestion. + */ + updatedForToken?: Token | null; + + addTemplate?: unknown; + prefixToAdd?: string; + suffixToAdd?: string; + textBoxPosition?: { lineNumber: number; column: number }; + urlTokenPath?: string[]; + otherTokenValues?: string; + requestStartRow?: number | null; + bodyTokenPath?: string[] | null; + endpointComponentResolver?: unknown; + globalComponentResolver?: unknown; + documentation?: string; +} diff --git a/src/plugins/console/public/lib/es/es.ts b/src/plugins/console/public/lib/es/es.ts index 03ee218fa2e1d..dffc2c9682cf2 100644 --- a/src/plugins/console/public/lib/es/es.ts +++ b/src/plugins/console/public/lib/es/es.ts @@ -19,7 +19,7 @@ export function getVersion() { return esVersion; } -export function getContentType(body: any) { +export function getContentType(body: unknown) { if (!body) return; return 'application/json'; } @@ -27,7 +27,7 @@ export function getContentType(body: any) { export function send( method: string, path: string, - data: any, + data: string | object, { asSystemRequest }: SendOptions = {} ) { const wrappedDfd = $.Deferred(); @@ -47,10 +47,10 @@ export function send( }; $.ajax(options).then( - (responseData: any, textStatus: string, jqXHR: any) => { + (responseData, textStatus: string, jqXHR: unknown) => { wrappedDfd.resolveWith({}, [responseData, textStatus, jqXHR]); }, - ((jqXHR: any, textStatus: string, errorThrown: Error) => { + ((jqXHR: { status: number; responseText: string }, textStatus: string, errorThrown: Error) => { if (jqXHR.status === 0) { jqXHR.responseText = "\n\nFailed to connect to Console's backend.\nPlease check the Kibana server is up and running"; diff --git a/src/plugins/console/public/lib/row_parser.ts b/src/plugins/console/public/lib/row_parser.ts index 5f8fb08ca1d6f..e18bf6ac6446b 100644 --- a/src/plugins/console/public/lib/row_parser.ts +++ b/src/plugins/console/public/lib/row_parser.ts @@ -75,7 +75,7 @@ export default class RowParser { return MODE.REQUEST_START; } - rowPredicate(lineNumber: number | undefined, editor: CoreEditor, value: any) { + rowPredicate(lineNumber: number | undefined, editor: CoreEditor, value: number) { const mode = this.getRowParseMode(lineNumber); // eslint-disable-next-line no-bitwise return (mode & value) > 0; diff --git a/src/plugins/console/public/lib/token_iterator/token_iterator.test.ts b/src/plugins/console/public/lib/token_iterator/token_iterator.test.ts index bc28e1d9e1a03..6b2f556f82a0e 100644 --- a/src/plugins/console/public/lib/token_iterator/token_iterator.test.ts +++ b/src/plugins/console/public/lib/token_iterator/token_iterator.test.ts @@ -15,7 +15,7 @@ const mockTokensProviderFactory = (tokenMtx: Token[][]): TokensProvider => { return tokenMtx[lineNumber - 1] || null; }, getTokenAt(pos: Position): Token | null { - return null as any; + return null; }, }; }; diff --git a/src/plugins/console/public/lib/utils/index.ts b/src/plugins/console/public/lib/utils/index.ts index 71b305807e61d..8b8974f4e2f0d 100644 --- a/src/plugins/console/public/lib/utils/index.ts +++ b/src/plugins/console/public/lib/utils/index.ts @@ -11,7 +11,7 @@ import { XJson } from '../../../../es_ui_shared/public'; const { collapseLiteralStrings, expandLiteralStrings } = XJson; -export function textFromRequest(request: any) { +export function textFromRequest(request: { method: string; url: string; data: string | string[] }) { let data = request.data; if (typeof data !== 'string') { data = data.join('\n'); @@ -19,7 +19,7 @@ export function textFromRequest(request: any) { return request.method + ' ' + request.url + '\n' + data; } -export function jsonToString(data: any, indent: boolean) { +export function jsonToString(data: object, indent: boolean) { return JSON.stringify(data, null, indent ? 2 : 0); } diff --git a/src/plugins/console/public/services/history.ts b/src/plugins/console/public/services/history.ts index 1dd18f8672a4b..ee1e97ceb386e 100644 --- a/src/plugins/console/public/services/history.ts +++ b/src/plugins/console/public/services/history.ts @@ -35,12 +35,12 @@ export class History { // be triggered from different places in the app. The alternative would be to store // this in state so that we hook into the React model, but it would require loading history // every time the application starts even if a user is not going to view history. - change(listener: (reqs: any[]) => void) { + change(listener: (reqs: unknown[]) => void) { const subscription = this.changeEmitter.subscribe(listener); return () => subscription.unsubscribe(); } - addToHistory(endpoint: string, method: string, data: any) { + addToHistory(endpoint: string, method: string, data?: string) { const keys = this.getHistoryKeys(); keys.splice(0, MAX_NUMBER_OF_HISTORY_ITEMS); // only maintain most recent X; keys.forEach((key) => { @@ -59,7 +59,7 @@ export class History { this.changeEmitter.next(this.getHistory()); } - updateCurrentState(content: any) { + updateCurrentState(content: string) { const timestamp = new Date().getTime(); this.storage.set('editor_state', { time: timestamp, diff --git a/src/plugins/console/public/services/index.ts b/src/plugins/console/public/services/index.ts index b6bcafc974b93..2b1e64525d0f9 100644 --- a/src/plugins/console/public/services/index.ts +++ b/src/plugins/console/public/services/index.ts @@ -8,4 +8,4 @@ export { createHistory, History } from './history'; export { createStorage, Storage, StorageKeys } from './storage'; -export { createSettings, Settings, DevToolsSettings } from './settings'; +export { createSettings, Settings, DevToolsSettings, DEFAULT_SETTINGS } from './settings'; diff --git a/src/plugins/console/public/services/settings.ts b/src/plugins/console/public/services/settings.ts index 8f142e876293e..647ac1e0ad09f 100644 --- a/src/plugins/console/public/services/settings.ts +++ b/src/plugins/console/public/services/settings.ts @@ -8,6 +8,14 @@ import { Storage } from './index'; +export const DEFAULT_SETTINGS = Object.freeze({ + fontSize: 14, + polling: true, + tripleQuotes: true, + wrapMode: true, + autocomplete: Object.freeze({ fields: true, indices: true, templates: true }), +}); + export interface DevToolsSettings { fontSize: number; wrapMode: boolean; @@ -24,50 +32,46 @@ export class Settings { constructor(private readonly storage: Storage) {} getFontSize() { - return this.storage.get('font_size', 14); + return this.storage.get('font_size', DEFAULT_SETTINGS.fontSize); } - setFontSize(size: any) { + setFontSize(size: number) { this.storage.set('font_size', size); return true; } getWrapMode() { - return this.storage.get('wrap_mode', true); + return this.storage.get('wrap_mode', DEFAULT_SETTINGS.wrapMode); } - setWrapMode(mode: any) { + setWrapMode(mode: boolean) { this.storage.set('wrap_mode', mode); return true; } - setTripleQuotes(tripleQuotes: any) { + setTripleQuotes(tripleQuotes: boolean) { this.storage.set('triple_quotes', tripleQuotes); return true; } getTripleQuotes() { - return this.storage.get('triple_quotes', true); + return this.storage.get('triple_quotes', DEFAULT_SETTINGS.tripleQuotes); } getAutocomplete() { - return this.storage.get('autocomplete_settings', { - fields: true, - indices: true, - templates: true, - }); + return this.storage.get('autocomplete_settings', DEFAULT_SETTINGS.autocomplete); } - setAutocomplete(settings: any) { + setAutocomplete(settings: object) { this.storage.set('autocomplete_settings', settings); return true; } getPolling() { - return this.storage.get('console_polling', true); + return this.storage.get('console_polling', DEFAULT_SETTINGS.polling); } - setPolling(polling: any) { + setPolling(polling: boolean) { this.storage.set('console_polling', polling); return true; } diff --git a/src/plugins/console/public/services/storage.ts b/src/plugins/console/public/services/storage.ts index d933024625e77..221020e496fec 100644 --- a/src/plugins/console/public/services/storage.ts +++ b/src/plugins/console/public/services/storage.ts @@ -17,11 +17,11 @@ export enum StorageKeys { export class Storage { constructor(private readonly engine: IStorageEngine, private readonly prefix: string) {} - encode(val: any) { + encode(val: unknown) { return JSON.stringify(val); } - decode(val: any) { + decode(val: string | null) { if (typeof val === 'string') { return JSON.parse(val); } @@ -37,7 +37,7 @@ export class Storage { } } - set(key: string, val: any) { + set(key: string, val: unknown) { this.engine.setItem(this.encodeKey(key), this.encode(val)); return val; } diff --git a/src/plugins/console/public/types/common.ts b/src/plugins/console/public/types/common.ts index 77b3d7c4477fc..53d896ad01d2f 100644 --- a/src/plugins/console/public/types/common.ts +++ b/src/plugins/console/public/types/common.ts @@ -11,6 +11,12 @@ export interface MetricsTracker { load: (eventName: string) => void; } +export interface ESRequest { + method: string; + endpoint: string; + data?: string; +} + export type BaseResponseType = | 'application/json' | 'text/csv' diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index f5e81f413d5c5..cc344d6bcc881 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -21,7 +21,7 @@ export type EditorEvent = export type AutoCompleterFunction = ( pos: Position, prefix: string, - callback: (...args: any[]) => void + callback: (...args: unknown[]) => void ) => void; export interface Position { @@ -235,7 +235,7 @@ export interface CoreEditor { * have this backdoor to update UI in response to request range changes, for example, as the user * moves the cursor around */ - legacyUpdateUI(opts: any): void; + legacyUpdateUI(opts: unknown): void; /** * A method to for the editor to resize, useful when, for instance, window size changes. @@ -250,7 +250,11 @@ export interface CoreEditor { /** * Register a keyboard shortcut and provide a function to be called. */ - registerKeyboardShortcut(opts: { keys: any; fn: () => void; name: string }): void; + registerKeyboardShortcut(opts: { + keys: string | { win?: string; mac?: string }; + fn: () => void; + name: string; + }): void; /** * Register a completions function that will be called when the editor diff --git a/src/plugins/console/server/lib/elasticsearch_proxy_config.ts b/src/plugins/console/server/lib/elasticsearch_proxy_config.ts index 757142c87a119..bad6942d0c9af 100644 --- a/src/plugins/console/server/lib/elasticsearch_proxy_config.ts +++ b/src/plugins/console/server/lib/elasticsearch_proxy_config.ts @@ -14,7 +14,7 @@ import url from 'url'; import { ESConfigForProxy } from '../types'; const createAgent = (legacyConfig: ESConfigForProxy) => { - const target = url.parse(_.head(legacyConfig.hosts) as any); + const target = url.parse(_.head(legacyConfig.hosts)!); if (!/^https/.test(target.protocol || '')) return new http.Agent(); const agentOptions: https.AgentOptions = {}; @@ -28,7 +28,7 @@ const createAgent = (legacyConfig: ESConfigForProxy) => { agentOptions.rejectUnauthorized = true; // by default, NodeJS is checking the server identify - agentOptions.checkServerIdentity = _.noop as any; + agentOptions.checkServerIdentity = (_.noop as unknown) as https.AgentOptions['checkServerIdentity']; break; case 'full': agentOptions.rejectUnauthorized = true; diff --git a/src/plugins/console/server/lib/proxy_config.ts b/src/plugins/console/server/lib/proxy_config.ts index 556f6affca91d..8e2633545799b 100644 --- a/src/plugins/console/server/lib/proxy_config.ts +++ b/src/plugins/console/server/lib/proxy_config.ts @@ -12,6 +12,17 @@ import { Agent as HttpsAgent, AgentOptions } from 'https'; import { WildcardMatcher } from './wildcard_matcher'; +interface Config { + match: { + protocol: string; + host: string; + port: string; + path: string; + }; + ssl?: { verify?: boolean; ca?: string; cert?: string; key?: string }; + timeout: number; +} + export class ProxyConfig { // @ts-ignore private id: string; @@ -26,9 +37,9 @@ export class ProxyConfig { private readonly sslAgent?: HttpsAgent; - private verifySsl: any; + private verifySsl: undefined | boolean; - constructor(config: { match: any; timeout: number }) { + constructor(config: Config) { config = { ...config, }; @@ -61,8 +72,8 @@ export class ProxyConfig { this.sslAgent = this._makeSslAgent(config); } - _makeSslAgent(config: any) { - const ssl = config.ssl || {}; + _makeSslAgent(config: Config) { + const ssl: Config['ssl'] = config.ssl || {}; this.verifySsl = ssl.verify; const sslAgentOpts: AgentOptions = { diff --git a/src/plugins/console/server/lib/proxy_config_collection.ts b/src/plugins/console/server/lib/proxy_config_collection.ts index 83900838332b5..f6c0b7c659de0 100644 --- a/src/plugins/console/server/lib/proxy_config_collection.ts +++ b/src/plugins/console/server/lib/proxy_config_collection.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { Agent } from 'http'; import { defaultsDeep } from 'lodash'; import { parse as parseUrl } from 'url'; @@ -14,7 +15,12 @@ import { ProxyConfig } from './proxy_config'; export class ProxyConfigCollection { private configs: ProxyConfig[]; - constructor(configs: Array<{ match: any; timeout: number }> = []) { + constructor( + configs: Array<{ + match: { protocol: string; host: string; port: string; path: string }; + timeout: number; + }> = [] + ) { this.configs = configs.map((settings) => new ProxyConfig(settings)); } @@ -22,7 +28,7 @@ export class ProxyConfigCollection { return Boolean(this.configs.length); } - configForUri(uri: string): object { + configForUri(uri: string): { agent: Agent; timeout: number } { const parsedUri = parseUrl(uri); const settings = this.configs.map((config) => config.getForParsedUri(parsedUri as any)); return defaultsDeep({}, ...settings); diff --git a/src/plugins/console/server/lib/proxy_request.test.ts b/src/plugins/console/server/lib/proxy_request.test.ts index 3fb915f0540b4..25257aa4c5579 100644 --- a/src/plugins/console/server/lib/proxy_request.test.ts +++ b/src/plugins/console/server/lib/proxy_request.test.ts @@ -55,7 +55,7 @@ describe(`Console's send request`, () => { fakeRequest = { abort: sinon.stub(), on() {}, - once(event: string, fn: any) { + once(event: string, fn: (v: string) => void) { if (event === 'response') { return fn('done'); } diff --git a/src/plugins/console/server/lib/proxy_request.ts b/src/plugins/console/server/lib/proxy_request.ts index d5914ab32bab7..46b4aa642a70e 100644 --- a/src/plugins/console/server/lib/proxy_request.ts +++ b/src/plugins/console/server/lib/proxy_request.ts @@ -46,9 +46,9 @@ export const proxyRequest = ({ const client = uri.protocol === 'https:' ? https : http; let resolved = false; - let resolve: any; - let reject: any; - const reqPromise = new Promise((res, rej) => { + let resolve: (res: http.IncomingMessage) => void; + let reject: (res: unknown) => void; + const reqPromise = new Promise((res, rej) => { resolve = res; reject = rej; }); diff --git a/src/plugins/console/server/routes/api/console/proxy/body.test.ts b/src/plugins/console/server/routes/api/console/proxy/body.test.ts index 64e2918764d00..80a2e075957de 100644 --- a/src/plugins/console/server/routes/api/console/proxy/body.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/body.test.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { IKibanaResponse } from 'src/core/server'; import { getProxyRouteHandlerDeps } from './mocks'; import { Readable } from 'stream'; @@ -16,10 +16,14 @@ import * as requestModule from '../../../../lib/proxy_request'; import { createResponseStub } from './stubs'; describe('Console Proxy Route', () => { - let request: any; + let request: ( + method: string, + path: string, + response?: string + ) => Promise | IKibanaResponse; beforeEach(() => { - request = (method: string, path: string, response: string) => { + request = (method, path, response) => { (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub(response)); const handler = createHandler(getProxyRouteHandlerDeps({})); diff --git a/src/plugins/console/server/routes/api/console/proxy/create_handler.ts b/src/plugins/console/server/routes/api/console/proxy/create_handler.ts index 290a2cdec7b76..6a514483d14f2 100644 --- a/src/plugins/console/server/routes/api/console/proxy/create_handler.ts +++ b/src/plugins/console/server/routes/api/console/proxy/create_handler.ts @@ -41,7 +41,7 @@ function toURL(base: string, path: string) { } function filterHeaders(originalHeaders: object, headersToKeep: string[]): object { - const normalizeHeader = function (header: any) { + const normalizeHeader = function (header: string) { if (!header) { return ''; } @@ -68,7 +68,7 @@ function getRequestConfig( return { ...proxyConfigCollection.configForUri(uri), headers: newHeaders, - } as any; + }; } return { @@ -81,7 +81,7 @@ function getProxyHeaders(req: KibanaRequest) { const headers = Object.create(null); // Scope this proto-unsafe functionality to where it is being used. - function extendCommaList(obj: Record, property: string, value: any) { + function extendCommaList(obj: Record, property: string, value: string) { obj[property] = (obj[property] ? obj[property] + ',' : '') + value; } @@ -142,7 +142,7 @@ export const createHandler = ({ }; esIncomingMessage = await proxyRequest({ - method: method.toLowerCase() as any, + method: method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head', headers: requestHeaders, uri, timeout, diff --git a/src/plugins/console/server/routes/api/console/proxy/query_string.test.ts b/src/plugins/console/server/routes/api/console/proxy/query_string.test.ts index 19b5070236872..be4f1dbab942f 100644 --- a/src/plugins/console/server/routes/api/console/proxy/query_string.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/query_string.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { IKibanaResponse } from 'src/core/server'; import { kibanaResponseFactory } from '../../../../../../../core/server'; import { getProxyRouteHandlerDeps } from './mocks'; import { createResponseStub } from './stubs'; @@ -14,7 +15,7 @@ import * as requestModule from '../../../../lib/proxy_request'; import { createHandler } from './create_handler'; describe('Console Proxy Route', () => { - let request: any; + let request: (method: string, path: string) => Promise | IKibanaResponse; beforeEach(() => { (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub('foo')); diff --git a/src/plugins/console/server/routes/api/console/proxy/stubs.ts b/src/plugins/console/server/routes/api/console/proxy/stubs.ts index 4aa23f99ea30e..3474d0c9b33b8 100644 --- a/src/plugins/console/server/routes/api/console/proxy/stubs.ts +++ b/src/plugins/console/server/routes/api/console/proxy/stubs.ts @@ -9,8 +9,12 @@ import { IncomingMessage } from 'http'; import { Readable } from 'stream'; -export function createResponseStub(response: any) { - const resp: any = new Readable({ +export function createResponseStub(response?: string) { + const resp: Readable & { + statusCode?: number; + statusMessage?: string; + headers?: Record; + } = new Readable({ read() { if (response) { this.push(response); diff --git a/src/plugins/console/server/services/spec_definitions_service.ts b/src/plugins/console/server/services/spec_definitions_service.ts index 8b0f4c04ae0bd..e0af9422666af 100644 --- a/src/plugins/console/server/services/spec_definitions_service.ts +++ b/src/plugins/console/server/services/spec_definitions_service.ts @@ -15,6 +15,15 @@ import { jsSpecLoaders } from '../lib'; const PATH_TO_OSS_JSON_SPEC = resolve(__dirname, '../lib/spec_definitions/json'); +interface EndpointDescription { + methods?: string[]; + patterns?: string | string[]; + url_params?: Record; + data_autocomplete_rules?: Record; + url_components?: Record; + priority?: number; +} + export class SpecDefinitionsService { private readonly name = 'es'; @@ -24,16 +33,23 @@ export class SpecDefinitionsService { private hasLoadedSpec = false; - public addGlobalAutocompleteRules(parentNode: string, rules: any) { + public addGlobalAutocompleteRules(parentNode: string, rules: unknown) { this.globalRules[parentNode] = rules; } - public addEndpointDescription(endpoint: string, description: any = {}) { - let copiedDescription: any = {}; + public addEndpointDescription(endpoint: string, description: EndpointDescription = {}) { + let copiedDescription: { patterns?: string; url_params?: Record } = {}; if (this.endpoints[endpoint]) { copiedDescription = { ...this.endpoints[endpoint] }; } - let urlParamsDef: any; + let urlParamsDef: + | { + ignore_unavailable?: string; + allow_no_indices?: string; + expand_wildcards?: string[]; + } + | undefined; + _.each(description.patterns || [], function (p) { if (p.indexOf('{indices}') >= 0) { urlParamsDef = urlParamsDef || {}; @@ -70,7 +86,7 @@ export class SpecDefinitionsService { this.extensionSpecFilePaths.push(path); } - public addProcessorDefinition(processor: any) { + public addProcessorDefinition(processor: unknown) { if (!this.hasLoadedSpec) { throw new Error( 'Cannot add a processor definition because spec definitions have not loaded!' @@ -104,11 +120,13 @@ export class SpecDefinitionsService { return generatedFiles.reduce((acc, file) => { const overrideFile = overrideFiles.find((f) => basename(f) === basename(file)); - const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); + const loadedSpec: Record = JSON.parse( + readFileSync(file, 'utf8') + ); if (overrideFile) { merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); } - const spec: any = {}; + const spec: Record = {}; Object.entries(loadedSpec).forEach(([key, value]) => { if (acc[key]) { // add time to remove key collision @@ -119,7 +137,7 @@ export class SpecDefinitionsService { }); return { ...acc, ...spec }; - }, {} as any); + }, {} as Record); } private loadJsonSpec() { diff --git a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts index c67cd325572ff..96725d4405112 100644 --- a/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts +++ b/src/plugins/dashboard/common/embeddable/embeddable_saved_object_converters.ts @@ -29,9 +29,6 @@ export function convertPanelStateToSavedDashboardPanel( panelState: DashboardPanelState, version: string ): SavedDashboardPanel { - const customTitle: string | undefined = panelState.explicitInput.title - ? (panelState.explicitInput.title as string) - : undefined; const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; return { version, @@ -39,7 +36,7 @@ export function convertPanelStateToSavedDashboardPanel( gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), - ...(customTitle && { title: customTitle }), + ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), ...(savedObjectId !== undefined && { id: savedObjectId }), }; } diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index 145aaa64fa3ad..60e74a3fa126c 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -21,7 +21,6 @@ It is wired into the `TopNavMenu` component, but can be used independently. ### Fetch Query Suggestions The `getQuerySuggestions` function helps to construct a query. -KQL suggestion functions are registered in X-Pack, so this API does not return results in OSS. ```.ts diff --git a/src/plugins/data/common/index_patterns/expressions/load_index_pattern.ts b/src/plugins/data/common/index_patterns/expressions/load_index_pattern.ts index 37a28fea53342..1c50f0704910a 100644 --- a/src/plugins/data/common/index_patterns/expressions/load_index_pattern.ts +++ b/src/plugins/data/common/index_patterns/expressions/load_index_pattern.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { IndexPatternsContract } from '../index_patterns'; import { IndexPatternSpec } from '..'; +import { SavedObjectReference } from '../../../../../core/types'; const name = 'indexPatternLoad'; const type = 'index_pattern'; @@ -57,4 +58,29 @@ export const getIndexPatternLoadMeta = (): Omit< }), }, }, + extract(state) { + const refName = 'indexPatternLoad.id'; + const references: SavedObjectReference[] = [ + { + name: refName, + type: 'search', + id: state.id[0] as string, + }, + ]; + return { + state: { + ...state, + id: [refName], + }, + references, + }; + }, + + inject(state, references) { + const reference = references.find((ref) => ref.name === 'indexPatternLoad.id'); + if (reference) { + state.id[0] = reference.id; + } + return state; + }, }); diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index f62fedc13b32a..0e9cf6aeb1f2f 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -232,7 +232,9 @@ export class AggConfig { const output = this.write(aggConfigs) as any; const configDsl = {} as any; - configDsl[this.type.dslName || this.type.name] = output.params; + if (!this.type.hasNoDslParams) { + configDsl[this.type.dslName || this.type.name] = output.params; + } // if the config requires subAggs, write them to the dsl as well if (this.subAggs.length) { diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts index c9986b7e93bed..03c702aa72fb5 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.ts @@ -21,7 +21,14 @@ import { TimeRange } from '../../../common'; function removeParentAggs(obj: any) { for (const prop in obj) { if (prop === 'parentAggs') delete obj[prop]; - else if (typeof obj[prop] === 'object') removeParentAggs(obj[prop]); + else if (typeof obj[prop] === 'object') { + const hasParentAggsKey = 'parentAggs' in obj[prop]; + removeParentAggs(obj[prop]); + // delete object if parentAggs was the last key + if (hasParentAggsKey && Object.keys(obj[prop]).length === 0) { + delete obj[prop]; + } + } } } @@ -193,10 +200,12 @@ export class AggConfigs { // advance the cursor and nest under the previous agg, or // put it on the same level if the previous agg doesn't accept // sub aggs - dslLvlCursor = prevDsl.aggs || dslLvlCursor; + dslLvlCursor = prevDsl?.aggs || dslLvlCursor; } - const dsl = (dslLvlCursor[config.id] = config.toDsl(this)); + const dsl = config.type.hasNoDslParams + ? config.toDsl(this) + : (dslLvlCursor[config.id] = config.toDsl(this)); let subAggs: any; parseParentAggs(dslLvlCursor, dsl); @@ -206,6 +215,11 @@ export class AggConfigs { subAggs = dsl.aggs || (dsl.aggs = {}); } + if (subAggs) { + _.each(subAggs, (agg) => { + parseParentAggs(subAggs, agg); + }); + } if (subAggs && nestedMetrics) { nestedMetrics.forEach((agg: any) => { subAggs[agg.config.id] = agg.dsl; diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 4583be17478e3..33fdc45a605b7 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -32,6 +32,7 @@ export interface AggTypeConfig< makeLabel?: ((aggConfig: TAggConfig) => string) | (() => string); ordered?: any; hasNoDsl?: boolean; + hasNoDslParams?: boolean; params?: Array>; valueType?: DatatableColumnType; getRequestAggs?: ((aggConfig: TAggConfig) => TAggConfig[]) | (() => TAggConfig[] | void); @@ -129,6 +130,12 @@ export class AggType< * @type {Boolean} */ hasNoDsl: boolean; + /** + * Flag that prevents params from this aggregation from being included in the dsl. Sibling and parent aggs are still written. + * + * @type {Boolean} + */ + hasNoDslParams: boolean; /** * The method to create a filter representation of the bucket * @param {object} aggConfig The instance of the aggConfig @@ -232,6 +239,7 @@ export class AggType< this.makeLabel = config.makeLabel || constant(this.name); this.ordered = config.ordered; this.hasNoDsl = !!config.hasNoDsl; + this.hasNoDslParams = !!config.hasNoDslParams; if (config.createFilter) { this.createFilter = config.createFilter; diff --git a/src/plugins/data/common/search/aggs/agg_types.ts b/src/plugins/data/common/search/aggs/agg_types.ts index a7af68a5ad8b4..d02f8e1fc5af4 100644 --- a/src/plugins/data/common/search/aggs/agg_types.ts +++ b/src/plugins/data/common/search/aggs/agg_types.ts @@ -44,6 +44,7 @@ export const getAggTypes = () => ({ { name: METRIC_TYPES.SUM_BUCKET, fn: metrics.getBucketSumMetricAgg }, { name: METRIC_TYPES.MIN_BUCKET, fn: metrics.getBucketMinMetricAgg }, { name: METRIC_TYPES.MAX_BUCKET, fn: metrics.getBucketMaxMetricAgg }, + { name: METRIC_TYPES.FILTERED_METRIC, fn: metrics.getFilteredMetricAgg }, { name: METRIC_TYPES.GEO_BOUNDS, fn: metrics.getGeoBoundsMetricAgg }, { name: METRIC_TYPES.GEO_CENTROID, fn: metrics.getGeoCentroidMetricAgg }, ], @@ -80,6 +81,7 @@ export const getAggTypesFunctions = () => [ metrics.aggBucketMax, metrics.aggBucketMin, metrics.aggBucketSum, + metrics.aggFilteredMetric, metrics.aggCardinality, metrics.aggCount, metrics.aggCumulativeSum, diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts index 92e6168b169c6..bba67640890ad 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts @@ -97,6 +97,7 @@ describe('Aggs service', () => { "sum_bucket", "min_bucket", "max_bucket", + "filtered_metric", "geo_bounds", "geo_centroid", ] @@ -142,6 +143,7 @@ describe('Aggs service', () => { "sum_bucket", "min_bucket", "max_bucket", + "filtered_metric", "geo_bounds", "geo_centroid", ] diff --git a/src/plugins/data/common/search/aggs/buckets/filter.ts b/src/plugins/data/common/search/aggs/buckets/filter.ts index 14a8f84c2cb9f..900848bb9517f 100644 --- a/src/plugins/data/common/search/aggs/buckets/filter.ts +++ b/src/plugins/data/common/search/aggs/buckets/filter.ts @@ -6,12 +6,15 @@ * Side Public License, v 1. */ +import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GeoBoundingBox } from './lib/geo_point'; import { aggFilterFnName } from './filter_fn'; import { BaseAggParams } from '../types'; +import { Query } from '../../../types'; +import { buildEsQuery, getEsQueryConfig } from '../../../es_query'; const filterTitle = i18n.translate('data.search.aggs.buckets.filterTitle', { defaultMessage: 'Filter', @@ -21,7 +24,7 @@ export interface AggParamsFilter extends BaseAggParams { geo_bounding_box?: GeoBoundingBox; } -export const getFilterBucketAgg = () => +export const getFilterBucketAgg = ({ getConfig }: { getConfig: (key: string) => any }) => new BucketAggType({ name: BUCKET_TYPES.FILTER, expressionName: aggFilterFnName, @@ -31,5 +34,27 @@ export const getFilterBucketAgg = () => { name: 'geo_bounding_box', }, + { + name: 'filter', + write(aggConfig, output) { + const filter: Query = aggConfig.params.filter; + + const input = cloneDeep(filter); + + if (!input) { + return; + } + + const esQueryConfigs = getEsQueryConfig({ get: getConfig }); + const query = buildEsQuery(aggConfig.getIndexPattern(), [input], [], esQueryConfigs); + + if (!query) { + console.log('malformed filter agg params, missing "query" on input'); // eslint-disable-line no-console + return; + } + + output.params = query; + }, + }, ], }); diff --git a/src/plugins/data/common/search/aggs/buckets/filter_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/filter_fn.test.ts index 0b9f2915e9aa4..8b4642bf595cd 100644 --- a/src/plugins/data/common/search/aggs/buckets/filter_fn.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/filter_fn.test.ts @@ -23,6 +23,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "filter": undefined, "geo_bounding_box": undefined, "json": undefined, }, @@ -46,6 +47,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "filter": undefined, "geo_bounding_box": Object { "wkt": "BBOX (-74.1, -71.12, 40.73, 40.01)", }, @@ -57,6 +59,25 @@ describe('agg_expression_functions', () => { `); }); + test('correctly parses filter string argument', () => { + const actual = fn({ + filter: '{ "language": "kuery", "query": "a: b" }', + }); + + expect(actual.value.params.filter).toEqual({ language: 'kuery', query: 'a: b' }); + }); + + test('errors out if geo_bounding_box is used together with filter', () => { + expect(() => + fn({ + filter: '{ "language": "kuery", "query": "a: b" }', + geo_bounding_box: JSON.stringify({ + wkt: 'BBOX (-74.1, -71.12, 40.73, 40.01)', + }), + }) + ).toThrow(); + }); + test('correctly parses json string argument', () => { const actual = fn({ json: '{ "foo": true }', diff --git a/src/plugins/data/common/search/aggs/buckets/filter_fn.ts b/src/plugins/data/common/search/aggs/buckets/filter_fn.ts index 468b063046549..4c68251f5e42e 100644 --- a/src/plugins/data/common/search/aggs/buckets/filter_fn.ts +++ b/src/plugins/data/common/search/aggs/buckets/filter_fn.ts @@ -17,7 +17,7 @@ export const aggFilterFnName = 'aggFilter'; type Input = any; type AggArgs = AggExpressionFunctionArgs; -type Arguments = Assign; +type Arguments = Assign; type Output = AggExpressionType; type FunctionDefinition = ExpressionFunctionDefinition< @@ -59,6 +59,13 @@ export const aggFilter = (): FunctionDefinition => ({ defaultMessage: 'Filter results based on a point location within a bounding box', }), }, + filter: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.filter.help', { + defaultMessage: + 'Filter results based on a kql or lucene query. Do not use together with geo_bounding_box', + }), + }, json: { types: ['string'], help: i18n.translate('data.search.aggs.buckets.filter.json.help', { @@ -75,6 +82,13 @@ export const aggFilter = (): FunctionDefinition => ({ fn: (input, args) => { const { id, enabled, schema, ...rest } = args; + const geoBoundingBox = getParsedValue(args, 'geo_bounding_box'); + const filter = getParsedValue(args, 'filter'); + + if (geoBoundingBox && filter) { + throw new Error("filter and geo_bounding_box can't be used together"); + } + return { type: 'agg_type', value: { @@ -84,7 +98,8 @@ export const aggFilter = (): FunctionDefinition => ({ type: BUCKET_TYPES.FILTER, params: { ...rest, - geo_bounding_box: getParsedValue(args, 'geo_bounding_box'), + geo_bounding_box: geoBoundingBox, + filter, }, }, }; diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.test.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.test.ts new file mode 100644 index 0000000000000..b27e4dd1494be --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AggConfigs, IAggConfigs } from '../agg_configs'; +import { mockAggTypesRegistry } from '../test_helpers'; +import { METRIC_TYPES } from './metric_agg_types'; + +describe('filtered metric agg type', () => { + let aggConfigs: IAggConfigs; + + beforeEach(() => { + const typesRegistry = mockAggTypesRegistry(); + const field = { + name: 'bytes', + }; + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: () => field, + filter: () => [field], + }, + } as any; + + aggConfigs = new AggConfigs( + indexPattern, + [ + { + id: METRIC_TYPES.FILTERED_METRIC, + type: METRIC_TYPES.FILTERED_METRIC, + schema: 'metric', + params: { + customBucket: { + type: 'filter', + params: { + filter: { language: 'kuery', query: 'a: b' }, + }, + }, + customMetric: { + type: 'cardinality', + params: { + field: 'bytes', + }, + }, + }, + }, + ], + { + typesRegistry, + } + ); + }); + + it('converts the response', () => { + const agg = aggConfigs.getResponseAggs()[0]; + + expect( + agg.getValue({ + 'filtered_metric-bucket': { + 'filtered_metric-metric': { + value: 10, + }, + }, + }) + ).toEqual(10); + }); +}); diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts new file mode 100644 index 0000000000000..aa2417bbf8415 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.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 { i18n } from '@kbn/i18n'; +import { MetricAggType } from './metric_agg_type'; +import { makeNestedLabel } from './lib/make_nested_label'; +import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; +import { METRIC_TYPES } from './metric_agg_types'; +import { AggConfigSerialized, BaseAggParams } from '../types'; +import { aggFilteredMetricFnName } from './filtered_metric_fn'; + +export interface AggParamsFilteredMetric extends BaseAggParams { + customMetric?: AggConfigSerialized; + customBucket?: AggConfigSerialized; +} + +const filteredMetricLabel = i18n.translate('data.search.aggs.metrics.filteredMetricLabel', { + defaultMessage: 'filtered', +}); + +const filteredMetricTitle = i18n.translate('data.search.aggs.metrics.filteredMetricTitle', { + defaultMessage: 'Filtered metric', +}); + +export const getFilteredMetricAgg = () => { + const { subtype, params, getSerializedFormat } = siblingPipelineAggHelper; + + return new MetricAggType({ + name: METRIC_TYPES.FILTERED_METRIC, + expressionName: aggFilteredMetricFnName, + title: filteredMetricTitle, + makeLabel: (agg) => makeNestedLabel(agg, filteredMetricLabel), + subtype, + params: [...params(['filter'])], + hasNoDslParams: true, + getSerializedFormat, + getValue(agg, bucket) { + const customMetric = agg.getParam('customMetric'); + const customBucket = agg.getParam('customBucket'); + return customMetric.getValue(bucket[customBucket.id]); + }, + getValueBucketPath(agg) { + const customBucket = agg.getParam('customBucket'); + const customMetric = agg.getParam('customMetric'); + if (customMetric.type.name === 'count') { + return customBucket.getValueBucketPath(); + } + return `${customBucket.getValueBucketPath()}>${customMetric.getValueBucketPath()}`; + }, + }); +}; diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts new file mode 100644 index 0000000000000..22e97fe18b604 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from '../test_helpers'; +import { aggFilteredMetric } from './filtered_metric_fn'; + +describe('agg_expression_functions', () => { + describe('aggFilteredMetric', () => { + const fn = functionWrapper(aggFilteredMetric()); + + test('handles customMetric and customBucket as a subexpression', () => { + const actual = fn({ + customMetric: fn({}), + customBucket: fn({}), + }); + + expect(actual.value.params).toMatchInlineSnapshot(` + Object { + "customBucket": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + }, + "schema": undefined, + "type": "filtered_metric", + }, + "customLabel": undefined, + "customMetric": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customBucket": undefined, + "customLabel": undefined, + "customMetric": undefined, + }, + "schema": undefined, + "type": "filtered_metric", + }, + } + `); + }); + }); +}); diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts new file mode 100644 index 0000000000000..6a7ff5fa5fd40 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { AggExpressionType, AggExpressionFunctionArgs, METRIC_TYPES } from '../'; + +export const aggFilteredMetricFnName = 'aggFilteredMetric'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; +type Arguments = Assign< + AggArgs, + { customBucket?: AggExpressionType; customMetric?: AggExpressionType } +>; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition< + typeof aggFilteredMetricFnName, + Input, + Arguments, + Output +>; + +export const aggFilteredMetric = (): FunctionDefinition => ({ + name: aggFilteredMetricFnName, + help: i18n.translate('data.search.aggs.function.metrics.filtered_metric.help', { + defaultMessage: 'Generates a serialized agg config for a filtered metric agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.filtered_metric.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.metrics.filtered_metric.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.filtered_metric.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + customBucket: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.filtered_metric.customBucket.help', { + defaultMessage: + 'Agg config to use for building sibling pipeline aggregations. Has to be a filter aggregation', + }), + }, + customMetric: { + types: ['agg_type'], + help: i18n.translate('data.search.aggs.metrics.filtered_metric.customMetric.help', { + defaultMessage: 'Agg config to use for building sibling pipeline aggregations', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.metrics.filtered_metric.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: METRIC_TYPES.FILTERED_METRIC, + params: { + ...rest, + customBucket: args.customBucket?.value, + customMetric: args.customMetric?.value, + }, + }, + }; + }, +}); diff --git a/src/plugins/data/common/search/aggs/metrics/index.ts b/src/plugins/data/common/search/aggs/metrics/index.ts index 4ab3b021ef93a..7038673d5d7c4 100644 --- a/src/plugins/data/common/search/aggs/metrics/index.ts +++ b/src/plugins/data/common/search/aggs/metrics/index.ts @@ -16,6 +16,8 @@ export * from './bucket_min_fn'; export * from './bucket_min'; export * from './bucket_sum_fn'; export * from './bucket_sum'; +export * from './filtered_metric_fn'; +export * from './filtered_metric'; export * from './cardinality_fn'; export * from './cardinality'; export * from './count'; diff --git a/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts b/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts index b72a6ca9f73ba..d51038d8a15e8 100644 --- a/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts +++ b/src/plugins/data/common/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts @@ -21,6 +21,7 @@ const metricAggFilter = [ '!std_dev', '!geo_bounds', '!geo_centroid', + '!filtered_metric', ]; export const parentPipelineType = i18n.translate( diff --git a/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts b/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts index d91ceae414f35..c0d1be4f47f9b 100644 --- a/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts +++ b/src/plugins/data/common/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts @@ -27,6 +27,7 @@ const metricAggFilter: string[] = [ '!cumulative_sum', '!geo_bounds', '!geo_centroid', + '!filtered_metric', ]; const bucketAggFilter: string[] = []; @@ -39,12 +40,12 @@ export const siblingPipelineType = i18n.translate( export const siblingPipelineAggHelper = { subtype: siblingPipelineType, - params() { + params(bucketFilter = bucketAggFilter) { return [ { name: 'customBucket', type: 'agg', - allowedAggs: bucketAggFilter, + allowedAggs: bucketFilter, default: null, makeAgg(agg: IMetricAggConfig, state = { type: 'date_histogram' }) { const orderAgg = agg.aggConfigs.createAggConfig(state, { addToAggConfigs: false }); @@ -69,7 +70,8 @@ export const siblingPipelineAggHelper = { modifyAggConfigOnSearchRequestStart: forwardModifyAggConfigOnSearchRequestStart( 'customMetric' ), - write: siblingPipelineAggWriter, + write: (agg: IMetricAggConfig, output: Record) => + siblingPipelineAggWriter(agg, output), }, ] as Array>; }, diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts index b49004be2db01..3b6c9d8a0d55d 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_types.ts @@ -8,6 +8,7 @@ export enum METRIC_TYPES { AVG = 'avg', + FILTERED_METRIC = 'filtered_metric', CARDINALITY = 'cardinality', AVG_BUCKET = 'avg_bucket', MAX_BUCKET = 'max_bucket', diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index 48ded7fa7a7fc..e57410962fc08 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -41,6 +41,7 @@ import { AggParamsBucketMax, AggParamsBucketMin, AggParamsBucketSum, + AggParamsFilteredMetric, AggParamsCardinality, AggParamsCumulativeSum, AggParamsDateHistogram, @@ -84,6 +85,7 @@ import { getCalculateAutoTimeExpression, METRIC_TYPES, AggConfig, + aggFilteredMetric, } from './'; export { IAggConfig, AggConfigSerialized } from './agg_config'; @@ -188,6 +190,7 @@ export interface AggParamsMapping { [METRIC_TYPES.MAX_BUCKET]: AggParamsBucketMax; [METRIC_TYPES.MIN_BUCKET]: AggParamsBucketMin; [METRIC_TYPES.SUM_BUCKET]: AggParamsBucketSum; + [METRIC_TYPES.FILTERED_METRIC]: AggParamsFilteredMetric; [METRIC_TYPES.CUMULATIVE_SUM]: AggParamsCumulativeSum; [METRIC_TYPES.DERIVATIVE]: AggParamsDerivative; [METRIC_TYPES.MOVING_FN]: AggParamsMovingAvg; @@ -217,6 +220,7 @@ export interface AggFunctionsMapping { aggBucketMax: ReturnType; aggBucketMin: ReturnType; aggBucketSum: ReturnType; + aggFilteredMetric: ReturnType; aggCardinality: ReturnType; aggCount: ReturnType; aggCumulativeSum: ReturnType; diff --git a/src/plugins/data/common/search/es_search/types.ts b/src/plugins/data/common/search/es_search/types.ts index dc1de8d1338f1..12dc0c1b2599d 100644 --- a/src/plugins/data/common/search/es_search/types.ts +++ b/src/plugins/data/common/search/es_search/types.ts @@ -5,19 +5,18 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; -import { SearchResponse } from 'elasticsearch'; -import { Search } from '@elastic/elasticsearch/api/requestParams'; import { IKibanaSearchRequest, IKibanaSearchResponse } from '../types'; export const ES_SEARCH_STRATEGY = 'es'; -export type ISearchRequestParams> = { +export type ISearchRequestParams = { trackTotalHits?: boolean; -} & Search; +} & estypes.SearchRequest; export interface IEsSearchRequest extends IKibanaSearchRequest { indexType?: string; } -export type IEsSearchResponse = IKibanaSearchResponse>; +export type IEsSearchResponse = IKibanaSearchResponse>; diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 98d7a2c45b4fc..22a7150d4a64e 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -15,6 +15,14 @@ import { Query, uniqFilters } from '../../query'; import { ExecutionContextSearch, KibanaContext, KibanaFilter } from './kibana_context_type'; import { KibanaQueryOutput } from './kibana_context_type'; import { KibanaTimerangeOutput } from './timerange'; +import { SavedObjectReference } from '../../../../../core/types'; +import { SavedObjectsClientCommon } from '../../index_patterns'; +import { Filter } from '../../es_query/filters'; + +/** @internal */ +export interface KibanaContextStartDependencies { + savedObjectsClient: SavedObjectsClientCommon; +} interface Arguments { q?: KibanaQueryOutput | null; @@ -40,75 +48,108 @@ const mergeQueries = (first: Query | Query[] = [], second: Query | Query[]) => (n: any) => JSON.stringify(n.query) ); -export const kibanaContextFunction: ExpressionFunctionKibanaContext = { - name: 'kibana_context', - type: 'kibana_context', - inputTypes: ['kibana_context', 'null'], - help: i18n.translate('data.search.functions.kibana_context.help', { - defaultMessage: 'Updates kibana global context', - }), - args: { - q: { - types: ['kibana_query', 'null'], - aliases: ['query', '_'], - default: null, - help: i18n.translate('data.search.functions.kibana_context.q.help', { - defaultMessage: 'Specify Kibana free form text query', - }), - }, - filters: { - types: ['kibana_filter', 'null'], - multi: true, - help: i18n.translate('data.search.functions.kibana_context.filters.help', { - defaultMessage: 'Specify Kibana generic filters', - }), +export const getKibanaContextFn = ( + getStartDependencies: ( + getKibanaRequest: ExecutionContext['getKibanaRequest'] + ) => Promise +) => { + const kibanaContextFunction: ExpressionFunctionKibanaContext = { + name: 'kibana_context', + type: 'kibana_context', + inputTypes: ['kibana_context', 'null'], + help: i18n.translate('data.search.functions.kibana_context.help', { + defaultMessage: 'Updates kibana global context', + }), + args: { + q: { + types: ['kibana_query', 'null'], + aliases: ['query', '_'], + default: null, + help: i18n.translate('data.search.functions.kibana_context.q.help', { + defaultMessage: 'Specify Kibana free form text query', + }), + }, + filters: { + types: ['kibana_filter', 'null'], + multi: true, + help: i18n.translate('data.search.functions.kibana_context.filters.help', { + defaultMessage: 'Specify Kibana generic filters', + }), + }, + timeRange: { + types: ['timerange', 'null'], + default: null, + help: i18n.translate('data.search.functions.kibana_context.timeRange.help', { + defaultMessage: 'Specify Kibana time range filter', + }), + }, + savedSearchId: { + types: ['string', 'null'], + default: null, + help: i18n.translate('data.search.functions.kibana_context.savedSearchId.help', { + defaultMessage: 'Specify saved search ID to be used for queries and filters', + }), + }, }, - timeRange: { - types: ['timerange', 'null'], - default: null, - help: i18n.translate('data.search.functions.kibana_context.timeRange.help', { - defaultMessage: 'Specify Kibana time range filter', - }), + + extract(state) { + const references: SavedObjectReference[] = []; + if (state.savedSearchId.length && typeof state.savedSearchId[0] === 'string') { + const refName = 'kibana_context.savedSearchId'; + references.push({ + name: refName, + type: 'search', + id: state.savedSearchId[0] as string, + }); + return { + state: { + ...state, + savedSearchId: [refName], + }, + references, + }; + } + return { state, references }; }, - savedSearchId: { - types: ['string', 'null'], - default: null, - help: i18n.translate('data.search.functions.kibana_context.savedSearchId.help', { - defaultMessage: 'Specify saved search ID to be used for queries and filters', - }), + + inject(state, references) { + const reference = references.find((r) => r.name === 'kibana_context.savedSearchId'); + if (reference) { + state.savedSearchId[0] = reference.id; + } + return state; }, - }, - async fn(input, args, { getSavedObject }) { - const timeRange = args.timeRange || input?.timeRange; - let queries = mergeQueries(input?.query, args?.q || []); - let filters = [...(input?.filters || []), ...(args?.filters?.map(unboxExpressionValue) || [])]; + async fn(input, args, { getKibanaRequest }) { + const { savedObjectsClient } = await getStartDependencies(getKibanaRequest); - if (args.savedSearchId) { - if (typeof getSavedObject !== 'function') { - throw new Error( - '"getSavedObject" function not available in execution context. ' + - 'When you execute expression you need to add extra execution context ' + - 'as the third argument and provide "getSavedObject" implementation.' - ); - } - const obj = await getSavedObject('search', args.savedSearchId); - const search = obj.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string }; - const { query, filter } = getParsedValue(search.searchSourceJSON, {}); + const timeRange = args.timeRange || input?.timeRange; + let queries = mergeQueries(input?.query, args?.q || []); + let filters = [ + ...(input?.filters || []), + ...((args?.filters?.map(unboxExpressionValue) || []) as Filter[]), + ]; - if (query) { - queries = mergeQueries(queries, query); - } - if (filter) { - filters = [...filters, ...(Array.isArray(filter) ? filter : [filter])]; + if (args.savedSearchId) { + const obj = await savedObjectsClient.get('search', args.savedSearchId); + const search = (obj.attributes as any).kibanaSavedObjectMeta.searchSourceJSON as string; + const { query, filter } = getParsedValue(search, {}); + + if (query) { + queries = mergeQueries(queries, query); + } + if (filter) { + filters = [...filters, ...(Array.isArray(filter) ? filter : [filter])]; + } } - } - return { - type: 'kibana_context', - query: queries, - filters: uniqFilters(filters).filter((f: any) => !f.meta?.disabled), - timeRange, - }; - }, + return { + type: 'kibana_context', + query: queries, + filters: uniqFilters(filters).filter((f: any) => !f.meta?.disabled), + timeRange, + }; + }, + }; + return kibanaContextFunction; }; diff --git a/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts b/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts index 6013b3d6c6f5f..99acbce8935c4 100644 --- a/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts +++ b/src/plugins/data/common/search/expressions/utils/courier_inspector_stats.ts @@ -14,7 +14,7 @@ */ import { i18n } from '@kbn/i18n'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ISearchSource } from 'src/plugins/data/public'; import { RequestStatistics } from 'src/plugins/inspector/common'; @@ -50,7 +50,7 @@ export function getRequestInspectorStats(searchSource: ISearchSource) { /** @public */ export function getResponseInspectorStats( - resp: SearchResponse, + resp: estypes.SearchResponse, searchSource?: ISearchSource ) { const lastRequest = diff --git a/src/plugins/data/common/search/search_source/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts index 14185d7d5afd3..d8c750d011b03 100644 --- a/src/plugins/data/common/search/search_source/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { KbnError } from '../../../../../kibana_utils/common'; import { SearchError } from './types'; @@ -16,8 +16,8 @@ import { SearchError } from './types'; * @param {Object} resp - optional HTTP response */ export class RequestFailure extends KbnError { - public resp?: SearchResponse; - constructor(err: SearchError | null = null, resp?: SearchResponse) { + public resp?: estypes.SearchResponse; + constructor(err: SearchError | null = null, resp?: estypes.SearchResponse) { super(`Request to Elasticsearch failed: ${JSON.stringify(resp || err?.message)}`); this.resp = resp; diff --git a/src/plugins/data/common/search/search_source/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts index 2387d9dbffa3a..8e8a9f1025b80 100644 --- a/src/plugins/data/common/search/search_source/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { LegacyFetchHandlers } from '../legacy/types'; import { GetConfigFn } from '../../../types'; @@ -25,7 +25,10 @@ export interface FetchHandlers { * Callback which can be used to hook into responses, modify them, or perform * side effects like displaying UI errors on the client. */ - onResponse: (request: SearchRequest, response: SearchResponse) => SearchResponse; + onResponse: ( + request: SearchRequest, + response: estypes.SearchResponse + ) => estypes.SearchResponse; /** * These handlers are only used by the legacy defaultSearchStrategy and can be removed * once that strategy has been deprecated. diff --git a/src/plugins/data/common/search/search_source/legacy/call_client.ts b/src/plugins/data/common/search/search_source/legacy/call_client.ts index a288cdc22c576..4c1156aac7015 100644 --- a/src/plugins/data/common/search/search_source/legacy/call_client.ts +++ b/src/plugins/data/common/search/search_source/legacy/call_client.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { FetchHandlers, SearchRequest } from '../fetch'; import { defaultSearchStrategy } from './default_search_strategy'; import { ISearchOptions } from '../../index'; @@ -21,7 +21,7 @@ export function callClient( [SearchRequest, ISearchOptions] > = searchRequests.map((request, i) => [request, requestsOptions[i]]); const requestOptionsMap = new Map(requestOptionEntries); - const requestResponseMap = new Map>>(); + const requestResponseMap = new Map>>(); const { searching, abort } = defaultSearchStrategy.search({ searchRequests, diff --git a/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts b/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts index e42ef6617594a..ff8ae2d19bd56 100644 --- a/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts +++ b/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { UI_SETTINGS } from '../../../constants'; import { FetchHandlers, SearchRequest } from '../fetch'; import { ISearchOptions } from '../../index'; @@ -57,9 +57,11 @@ async function delayedFetch( options: ISearchOptions, fetchHandlers: FetchHandlers, ms: number -): Promise> { +): Promise> { if (ms === 0) { - return callClient([request], [options], fetchHandlers)[0]; + return callClient([request], [options], fetchHandlers)[0] as Promise< + estypes.SearchResponse + >; } const i = requestsToFetch.length; diff --git a/src/plugins/data/common/search/search_source/legacy/types.ts b/src/plugins/data/common/search/search_source/legacy/types.ts index 5a60d1082b0ed..a4328528fd662 100644 --- a/src/plugins/data/common/search/search_source/legacy/types.ts +++ b/src/plugins/data/common/search/search_source/legacy/types.ts @@ -7,8 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { ApiResponse } from '@elastic/elasticsearch'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes, ApiResponse } from '@elastic/elasticsearch'; import { FetchHandlers, SearchRequest } from '../fetch'; interface MsearchHeaders { @@ -28,7 +27,7 @@ export interface MsearchRequestBody { // @internal export interface MsearchResponse { - body: ApiResponse<{ responses: Array> }>; + body: ApiResponse<{ responses: Array> }>; } // @internal @@ -51,6 +50,6 @@ export interface SearchStrategyProvider { } export interface SearchStrategyResponse { - searching: Promise>>; + searching: Promise>>; abort: () => void; } diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index 6b288c4507f06..eb9d859664c4d 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -18,6 +18,11 @@ import { import { ConfigSchema } from '../../config'; import { UsageCollectionSetup } from '../../../usage_collection/public'; import { createUsageCollector } from './collectors'; +import { + KUERY_LANGUAGE_NAME, + setupKqlQuerySuggestionProvider, +} from './providers/kql_query_suggestion'; +import { DataPublicPluginStart, DataStartDependencies } from '../types'; export class AutocompleteService { autocompleteConfig: ConfigSchema['autocomplete']; @@ -31,12 +36,6 @@ export class AutocompleteService { private readonly querySuggestionProviders: Map = new Map(); private getValueSuggestions?: ValueSuggestionsGetFn; - private addQuerySuggestionProvider = (language: string, provider: QuerySuggestionGetFn): void => { - if (language && provider && this.autocompleteConfig.querySuggestions.enabled) { - this.querySuggestionProviders.set(language, provider); - } - }; - private getQuerySuggestions: QuerySuggestionGetFn = (args) => { const { language } = args; const provider = this.querySuggestionProviders.get(language); @@ -50,7 +49,7 @@ export class AutocompleteService { /** @public **/ public setup( - core: CoreSetup, + core: CoreSetup, { timefilter, usageCollection, @@ -62,11 +61,15 @@ export class AutocompleteService { ? setupValueSuggestionProvider(core, { timefilter, usageCollector }) : getEmptyValueSuggestions; - return { - addQuerySuggestionProvider: this.addQuerySuggestionProvider, + if (this.autocompleteConfig.querySuggestions.enabled) { + this.querySuggestionProviders.set(KUERY_LANGUAGE_NAME, setupKqlQuerySuggestionProvider(core)); + } - /** @obsolete **/ - /** please use "getProvider" only from the start contract **/ + return { + /** + * @deprecated + * please use "getQuerySuggestions" from the start contract + */ getQuerySuggestions: this.getQuerySuggestions, }; } diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md new file mode 100644 index 0000000000000..2ab87a7a490c1 --- /dev/null +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md @@ -0,0 +1 @@ +This is implementation of KQL query suggestions diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json similarity index 100% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts index 5e562ae63e91b..c1c44f1f55548 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { setupGetConjunctionSuggestions } from './conjunction'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx similarity index 67% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx index 7efc2ea193abe..345f9f8051e5d 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React from 'react'; @@ -16,17 +17,17 @@ import { const bothArgumentsText = ( ); const oneOrMoreArgumentsText = ( ); @@ -34,20 +35,20 @@ const conjunctions: Record = { and: (

{bothArgumentsText}, }} description="Full text: ' Requires both arguments to be true'. See - 'xpack.data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." + 'data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." />

), or: (

= { ), }} description="Full text: 'Requires one or more arguments to be true'. See - 'xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." + 'data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." />

), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts similarity index 97% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts index afc55d13af9d9..f1eced06a33ea 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx index ac6f7de888320..5cafca168dfa2 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React from 'react'; @@ -22,7 +23,7 @@ const getDescription = (field: IFieldType) => { return (

{field.name} }} /> diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts index 8b36480a35b17..c5c1626ae74f6 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { CoreSetup } from 'kibana/public'; @@ -17,6 +18,7 @@ import { QuerySuggestion, QuerySuggestionGetFnArgs, QuerySuggestionGetFn, + DataPublicPluginStart, } from '../../../../../../../src/plugins/data/public'; const cursorSymbol = '@kuery-cursor@'; @@ -26,7 +28,9 @@ const dedup = (suggestions: QuerySuggestion[]): QuerySuggestion[] => export const KUERY_LANGUAGE_NAME = 'kuery'; -export const setupKqlQuerySuggestionProvider = (core: CoreSetup): QuerySuggestionGetFn => { +export const setupKqlQuerySuggestionProvider = ( + core: CoreSetup +): QuerySuggestionGetFn => { const providers = { field: setupGetFieldSuggestions(core), value: setupGetValueSuggestions(core), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts index 0173617a99b1b..933449e779ef7 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { escapeQuotes, escapeKuery } from './escape_kuery'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts index 901e61bde455d..54f03803a893e 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { flow } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts similarity index 95% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts index bd021b0d0dac5..4debbc0843d51 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx index cfe935e4b1990..618e33ddf345a 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React from 'react'; @@ -15,44 +16,44 @@ import { QuerySuggestionTypes } from '../../../../../../../src/plugins/data/publ const equalsText = ( ); const lessThanOrEqualToText = ( ); const greaterThanOrEqualToText = ( ); const lessThanText = ( ); const greaterThanText = ( ); const existsText = ( ); @@ -60,11 +61,11 @@ const operators = { ':': { description: ( {equalsText} }} description="Full text: 'equals some value'. See - 'xpack.data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." + 'data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." /> ), fieldTypes: [ @@ -83,7 +84,7 @@ const operators = { '<=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -99,7 +100,7 @@ const operators = { '>=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -115,11 +116,11 @@ const operators = { '<': { description: ( {lessThanText} }} description="Full text: 'is less than some value'. See - 'xpack.data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." + 'data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -127,13 +128,13 @@ const operators = { '>': { description: ( {greaterThanText}, }} description="Full text: 'is greater than some value'. See - 'xpack.data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." + 'data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -141,11 +142,11 @@ const operators = { ': *': { description: ( {existsText} }} description="Full text: 'exists in any form'. See - 'xpack.data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." + 'data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." /> ), fieldTypes: undefined, diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts index aa236a45fa93c..f72fb75684105 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { sortPrefixFirst } from './sort_prefix_first'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts similarity index 76% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts index c344197641ef4..25bc32d47f338 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts @@ -1,8 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { partition } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts index b5abdbee51832..48e87a73f3671 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts @@ -1,17 +1,19 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { CoreSetup } from 'kibana/public'; import { + DataPublicPluginStart, KueryNode, QuerySuggestionBasic, QuerySuggestionGetFnArgs, } from '../../../../../../../src/plugins/data/public'; export type KqlQuerySuggestionProvider = ( - core: CoreSetup + core: CoreSetup ) => (querySuggestionsGetFnArgs: QuerySuggestionGetFnArgs, kueryNode: KueryNode) => Promise; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts similarity index 93% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts index 5744dad43dcdd..c434d9a8ef365 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts @@ -1,15 +1,15 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { setupGetValueSuggestions } from './value'; import indexPatternResponse from './__fixtures__/index_pattern_response.json'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { QuerySuggestionGetFnArgs, KueryNode } from '../../../../../../../src/plugins/data/public'; -import { setAutocompleteService } from '../../../services'; const mockKueryNode = (kueryNode: Partial) => (kueryNode as unknown) as KueryNode; @@ -19,11 +19,6 @@ describe('Kuery value suggestions', () => { let autocompleteServiceMock: any; beforeEach(() => { - getSuggestions = setupGetValueSuggestions(coreMock.createSetup()); - querySuggestionsArgs = ({ - indexPatterns: [indexPatternResponse], - } as unknown) as QuerySuggestionGetFnArgs; - autocompleteServiceMock = { getValueSuggestions: jest.fn(({ field }) => { let res: any[]; @@ -40,7 +35,16 @@ describe('Kuery value suggestions', () => { return Promise.resolve(res); }), }; - setAutocompleteService(autocompleteServiceMock); + + const coreSetup = coreMock.createSetup({ + pluginStartContract: { + autocomplete: autocompleteServiceMock, + }, + }); + getSuggestions = setupGetValueSuggestions(coreSetup); + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as QuerySuggestionGetFnArgs; jest.clearAllMocks(); }); diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts similarity index 79% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts index 92fd4d7b71bdc..f8fc9d165fc6b 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts @@ -1,15 +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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { flatten } from 'lodash'; +import { CoreSetup } from 'kibana/public'; import { escapeQuotes } from './lib/escape_kuery'; import { KqlQuerySuggestionProvider } from './types'; -import { getAutocompleteService } from '../../../services'; import { + DataPublicPluginStart, IFieldType, IIndexPattern, QuerySuggestion, @@ -26,7 +28,12 @@ const wrapAsSuggestions = (start: number, end: number, query: string, values: st end, })); -export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { +export const setupGetValueSuggestions: KqlQuerySuggestionProvider = ( + core: CoreSetup +) => { + const autoCompleteServicePromise = core + .getStartServices() + .then(([_, __, dataStart]) => dataStart.autocomplete); return async ( { indexPatterns, boolFilter, useTimeRange, signal }, { start, end, prefix, suffix, fieldName, nestedPath } @@ -41,7 +48,7 @@ export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { }); const query = `${prefix}${suffix}`.trim(); - const { getValueSuggestions } = getAutocompleteService(); + const { getValueSuggestions } = await autoCompleteServicePromise; const data = await Promise.all( indexPatternFieldEntries.map(([indexPattern, field]) => diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index a3676c5116927..573820890de71 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -17,7 +17,6 @@ export type Setup = jest.Mocked>; export type Start = jest.Mocked>; const automcompleteSetupMock: jest.Mocked = { - addQuerySuggestionProvider: jest.fn(), getQuerySuggestions: jest.fn(), }; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index a61b8f400d285..95d7a35a45320 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -23,11 +23,12 @@ import * as CSS from 'csstype'; import { Datatable as Datatable_2 } from 'src/plugins/expressions'; import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; -import { DatatableColumnType } from 'src/plugins/expressions/common'; +import { DatatableColumnType as DatatableColumnType_2 } from 'src/plugins/expressions/common'; import { DetailedPeerCertificate } from 'tls'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; import { ErrorToastOptions } from 'src/core/public/notifications'; +import { estypes } from '@elastic/elasticsearch'; import { EuiBreadcrumb } from '@elastic/eui'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; @@ -84,16 +85,14 @@ import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common'; import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; -import { SavedObject } from 'kibana/server'; -import { SavedObject as SavedObject_2 } from 'src/core/server'; +import { SavedObject } from 'src/core/server'; +import { SavedObject as SavedObject_2 } from 'kibana/server'; import { SavedObjectReference } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectsFindOptions } from 'kibana/public'; import { SavedObjectsFindResponse } from 'kibana/server'; import { SavedObjectsUpdateResponse } from 'kibana/server'; import { SchemaTypeError } from '@kbn/config-schema'; -import { Search } from '@elastic/elasticsearch/api/requestParams'; -import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; import { StartServicesAccessor } from 'kibana/public'; import { ToastInputFields } from 'src/core/public/notifications'; @@ -189,7 +188,7 @@ export class AggConfig { // @deprecated (undocumented) toJSON(): AggConfigSerialized; // Warning: (ae-forgotten-export) The symbol "SerializableState" needs to be exported by the entry point index.d.ts - toSerializedFieldFormat(): {} | Ensure, SerializableState>; + toSerializedFieldFormat(): {} | Ensure, SerializableState_2>; // (undocumented) get type(): IAggType; set type(type: IAggType); @@ -273,9 +272,9 @@ export type AggConfigSerialized = Ensure<{ type: string; enabled?: boolean; id?: string; - params?: {} | SerializableState; + params?: {} | SerializableState_2; schema?: string; -}, SerializableState>; +}, SerializableState_2>; // Warning: (ae-missing-release-tag) "AggFunctionsMapping" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -329,6 +328,10 @@ export interface AggFunctionsMapping { // // (undocumented) aggFilter: ReturnType; + // Warning: (ae-forgotten-export) The symbol "aggFilteredMetric" needs to be exported by the entry point index.d.ts + // + // (undocumented) + aggFilteredMetric: ReturnType; // Warning: (ae-forgotten-export) The symbol "aggFilters" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -711,7 +714,7 @@ export const ES_SEARCH_STRATEGY = "es"; // Warning: (ae-missing-release-tag) "EsaggsExpressionFunctionDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'esaggs', Input_34, Arguments_20, Output_34>; +export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'esaggs', Input_35, Arguments_21, Output_35>; // Warning: (ae-forgotten-export) The symbol "name" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Input" needs to be exported by the entry point index.d.ts @@ -720,7 +723,7 @@ export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'e // Warning: (ae-missing-release-tag) "EsdslExpressionFunctionDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition_2; +export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition_2; // Warning: (ae-missing-release-tag) "esFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -861,7 +864,7 @@ export type ExpressionFunctionKibana = ExpressionFunctionDefinition<'kibana', Ex // Warning: (ae-missing-release-tag) "ExpressionFunctionKibanaContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type ExpressionFunctionKibanaContext = ExpressionFunctionDefinition<'kibana_context', KibanaContext | null, Arguments_21, Promise, ExecutionContext>; +export type ExpressionFunctionKibanaContext = ExpressionFunctionDefinition<'kibana_context', KibanaContext | null, Arguments_22, Promise, ExecutionContext>; // Warning: (ae-missing-release-tag) "ExpressionValueSearchContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1124,7 +1127,7 @@ export interface IEsSearchRequest extends IKibanaSearchRequest = IKibanaSearchResponse>; +export type IEsSearchResponse = IKibanaSearchResponse>; // Warning: (ae-missing-release-tag) "IFieldFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1601,7 +1604,7 @@ export class IndexPatternsService { // Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts // // (undocumented) - getCache: () => Promise[] | null | undefined>; + getCache: () => Promise[] | null | undefined>; getDefault: () => Promise; getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise; // Warning: (ae-forgotten-export) The symbol "GetFieldsOptions" needs to be exported by the entry point index.d.ts @@ -1613,7 +1616,7 @@ export class IndexPatternsService { }>>; getTitles: (refresh?: boolean) => Promise; refreshFields: (indexPattern: IndexPattern) => Promise; - savedObjectToSpec: (savedObject: SavedObject_2) => IndexPatternSpec; + savedObjectToSpec: (savedObject: SavedObject) => IndexPatternSpec; setDefault: (id: string, force?: boolean) => Promise; updateSavedObject(indexPattern: IndexPattern, saveAttempts?: number, ignoreErrors?: boolean): Promise; } @@ -1830,6 +1833,8 @@ export enum METRIC_TYPES { // (undocumented) DERIVATIVE = "derivative", // (undocumented) + FILTERED_METRIC = "filtered_metric", + // (undocumented) GEO_BOUNDS = "geo_bounds", // (undocumented) GEO_CENTROID = "geo_centroid", @@ -2409,9 +2414,9 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): import("rxjs").Observable>; + fetch$(options?: ISearchOptions): import("rxjs").Observable>; // @deprecated - fetch(options?: ISearchOptions): Promise>; + fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): SearchSourceFields; getId(): string; @@ -2649,7 +2654,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:65:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/search/aggs/types.ts:141:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:56:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:56:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index 63c27eeaf0b11..7e9170b98f132 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -54,7 +54,7 @@ describe('AggsService - public', () => { service.setup(setupDeps); const start = service.start(startDeps); expect(start.types.getAll().buckets.length).toBe(11); - expect(start.types.getAll().metrics.length).toBe(21); + expect(start.types.getAll().metrics.length).toBe(22); }); test('registers custom agg types', () => { @@ -71,7 +71,7 @@ describe('AggsService - public', () => { const start = service.start(startDeps); expect(start.types.getAll().buckets.length).toBe(12); expect(start.types.getAll().buckets.some(({ name }) => name === 'foo')).toBe(true); - expect(start.types.getAll().metrics.length).toBe(22); + expect(start.types.getAll().metrics.length).toBe(23); expect(start.types.getAll().metrics.some(({ name }) => name === 'bar')).toBe(true); }); }); diff --git a/src/plugins/data/public/search/expressions/es_raw_response.ts b/src/plugins/data/public/search/expressions/es_raw_response.ts index 6b44a7afb6d67..2d12af017d88c 100644 --- a/src/plugins/data/public/search/expressions/es_raw_response.ts +++ b/src/plugins/data/public/search/expressions/es_raw_response.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ExpressionTypeDefinition } from '../../../../expressions/common'; const name = 'es_raw_response'; export interface EsRawResponse { type: typeof name; - body: SearchResponse; + body: estypes.SearchResponse; } // flattens elasticsearch object into table rows @@ -46,11 +46,11 @@ function flatten(obj: any, keyPrefix = '') { } } -const parseRawDocs = (hits: SearchResponse['hits']) => { +const parseRawDocs = (hits: estypes.SearchResponse['hits']) => { return hits.hits.map((hit) => hit.fields || hit._source).filter((hit) => hit); }; -const convertResult = (body: SearchResponse) => { +const convertResult = (body: estypes.SearchResponse) => { return !body.aggregations ? parseRawDocs(body.hits) : flatten(body.aggregations); }; diff --git a/src/plugins/data/public/search/expressions/kibana_context.ts b/src/plugins/data/public/search/expressions/kibana_context.ts new file mode 100644 index 0000000000000..e7ce8edf3080a --- /dev/null +++ b/src/plugins/data/public/search/expressions/kibana_context.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 { StartServicesAccessor } from 'src/core/public'; +import { getKibanaContextFn } from '../../../common/search/expressions'; +import { DataPublicPluginStart, DataStartDependencies } from '../../types'; +import { SavedObjectsClientCommon } from '../../../common/index_patterns'; + +/** + * This is some glue code that takes in `core.getStartServices`, extracts the dependencies + * needed for this function, and wraps them behind a `getStartDependencies` function that + * is then called at runtime. + * + * We do this so that we can be explicit about exactly which dependencies the function + * requires, without cluttering up the top-level `plugin.ts` with this logic. It also + * makes testing the expression function a bit easier since `getStartDependencies` is + * the only thing you should need to mock. + * + * @param getStartServices - core's StartServicesAccessor for this plugin + * + * @internal + */ +export function getKibanaContext({ + getStartServices, +}: { + getStartServices: StartServicesAccessor; +}) { + return getKibanaContextFn(async () => { + const [core] = await getStartServices(); + return { + savedObjectsClient: (core.savedObjects.client as unknown) as SavedObjectsClientCommon, + }; + }); +} diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 00d5b11089d62..57ee5737e50a2 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: SearchResponse) { +export function handleResponse(request: SearchRequest, response: estypes.SearchResponse) { if (response.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 94fa5b7230f69..a3acd775ee892 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -21,7 +21,6 @@ import { handleResponse } from './fetch'; import { kibana, kibanaContext, - kibanaContextFunction, ISearchGeneric, SearchSourceDependencies, SearchSourceService, @@ -52,6 +51,7 @@ import { import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; import { DataPublicPluginStart, DataStartDependencies } from '../types'; import { NowProviderInternalContract } from '../now_provider'; +import { getKibanaContext } from './expressions/kibana_context'; /** @internal */ export interface SearchServiceSetupDependencies { @@ -110,7 +110,11 @@ export class SearchService implements Plugin { }) ); expressions.registerFunction(kibana); - expressions.registerFunction(kibanaContextFunction); + expressions.registerFunction( + getKibanaContext({ getStartServices } as { + getStartServices: StartServicesAccessor; + }) + ); expressions.registerFunction(luceneFunction); expressions.registerFunction(kqlFunction); expressions.registerFunction(kibanaTimerangeFunction); diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_modal.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_modal.tsx index f510420cb30e8..8e6ad4bc92c8f 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_modal.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_modal.tsx @@ -21,14 +21,14 @@ import { EuiButtonEmpty, EuiCallOut, } from '@elastic/eui'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ShardFailureTable } from './shard_failure_table'; import { ShardFailureRequest } from './shard_failure_types'; export interface Props { onClose: () => void; request: ShardFailureRequest; - response: SearchResponse; + response: estypes.SearchResponse; title: string; } diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx index 0907d6607579f..a230378d6c3d3 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx @@ -9,8 +9,8 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiTextAlign } from '@elastic/eui'; +import type { estypes } from '@elastic/elasticsearch'; -import { SearchResponse } from 'elasticsearch'; import { getOverlays } from '../../services'; import { toMountPoint } from '../../../../kibana_react/public'; import { ShardFailureModal } from './shard_failure_modal'; @@ -19,7 +19,7 @@ import { ShardFailureRequest } from './shard_failure_types'; // @internal export interface ShardFailureOpenModalButtonProps { request: ShardFailureRequest; - response: SearchResponse; + response: estypes.SearchResponse; title: string; } diff --git a/src/plugins/data/server/autocomplete/routes.ts b/src/plugins/data/server/autocomplete/routes.ts index fc6bb0b69c102..c453094ff6874 100644 --- a/src/plugins/data/server/autocomplete/routes.ts +++ b/src/plugins/data/server/autocomplete/routes.ts @@ -9,10 +9,9 @@ import { Observable } from 'rxjs'; import { CoreSetup, SharedGlobalConfig } from 'kibana/server'; import { registerValueSuggestionsRoute } from './value_suggestions_route'; -import { DataRequestHandlerContext } from '../types'; export function registerRoutes({ http }: CoreSetup, config$: Observable): void { - const router = http.createRouter(); + const router = http.createRouter(); registerValueSuggestionsRoute(router, config$); } diff --git a/src/plugins/data/server/autocomplete/value_suggestions_route.ts b/src/plugins/data/server/autocomplete/value_suggestions_route.ts index 8e6d3afa18ed5..bdcc13ce4c061 100644 --- a/src/plugins/data/server/autocomplete/value_suggestions_route.ts +++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts @@ -12,12 +12,13 @@ import { IRouter, SharedGlobalConfig } from 'kibana/server'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { IFieldType, Filter, ES_SEARCH_STRATEGY, IEsSearchRequest } from '../index'; +import type { estypes } from '@elastic/elasticsearch'; +import type { IFieldType } from '../index'; +import { findIndexPatternById, getFieldByName } from '../index_patterns'; import { getRequestAbortedSignal } from '../lib'; -import { DataRequestHandlerContext } from '../types'; export function registerValueSuggestionsRoute( - router: IRouter, + router: IRouter, config$: Observable ) { router.post( @@ -44,40 +45,24 @@ export function registerValueSuggestionsRoute( const config = await config$.pipe(first()).toPromise(); const { field: fieldName, query, filters } = request.body; const { index } = request.params; + const { client } = context.core.elasticsearch.legacy; const signal = getRequestAbortedSignal(request.events.aborted$); - if (!context.indexPatterns) { - return response.badRequest(); - } - const autocompleteSearchOptions = { timeout: `${config.kibana.autocompleteTimeout.asMilliseconds()}ms`, terminate_after: config.kibana.autocompleteTerminateAfter.asMilliseconds(), }; - const indexPatterns = await context.indexPatterns.find(index, 1); - if (!indexPatterns || indexPatterns.length === 0) { - return response.notFound(); - } - const field = indexPatterns[0].getFieldByName(fieldName); + const indexPattern = await findIndexPatternById(context.core.savedObjects.client, index); + + const field = indexPattern && getFieldByName(fieldName, indexPattern); const body = await getBody(autocompleteSearchOptions, field || fieldName, query, filters); - const searchRequest: IEsSearchRequest = { - params: { - index, - body, - }, - }; - const { rawResponse } = await context.search - .search(searchRequest, { - strategy: ES_SEARCH_STRATEGY, - abortSignal: signal, - }) - .toPromise(); + const result = await client.callAsCurrentUser('search', { index, body }, { signal }); const buckets: any[] = - get(rawResponse, 'aggregations.suggestions.buckets') || - get(rawResponse, 'aggregations.nestedSuggestions.suggestions.buckets'); + get(result, 'aggregations.suggestions.buckets') || + get(result, 'aggregations.nestedSuggestions.suggestions.buckets'); return response.ok({ body: map(buckets || [], 'key') }); } @@ -89,7 +74,7 @@ async function getBody( { timeout, terminate_after }: Record, field: IFieldType | string, query: string, - filters: Filter[] = [] + filters: estypes.QueryContainer[] = [] ) { const isFieldObject = (f: any): f is IFieldType => Boolean(f && f.name); @@ -98,7 +83,7 @@ async function getBody( q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`); // Helps ensure that the regex is not evaluated eagerly against the terms dictionary - const executionHint = 'map'; + const executionHint = 'map' as const; // We don't care about the accuracy of the counts, just the content of the terms, so this reduces // the amount of information that needs to be transmitted to the coordinating node diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index c153c0efa8892..cbf09ef57d96a 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -236,10 +236,10 @@ export { SearchUsage, SearchSessionService, ISearchSessionService, + SearchRequestHandlerContext, + DataRequestHandlerContext, } from './search'; -export { DataRequestHandlerContext } from './types'; - // Search namespace export const search = { aggs: { diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts b/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts index db950e7aa48f9..69a9280dd93d8 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts @@ -8,17 +8,6 @@ import { ElasticsearchClient } from 'kibana/server'; import { convertEsError } from './errors'; -import { FieldCapsResponse } from './field_capabilities'; - -export interface IndicesAliasResponse { - [index: string]: IndexAliasResponse; -} - -export interface IndexAliasResponse { - aliases: { - [aliasName: string]: Record; - }; -} /** * Call the index.getAlias API for a list of indices. @@ -67,7 +56,7 @@ export async function callFieldCapsApi( fieldCapsOptions: { allow_no_indices: boolean } = { allow_no_indices: false } ) { try { - return await callCluster.fieldCaps({ + return await callCluster.fieldCaps({ index: indices, fields: '*', ignore_unavailable: true, diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_caps_response.ts b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_caps_response.ts index 31fd60b0382aa..c4c1ffa3cf9f9 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_caps_response.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_caps_response.ts @@ -7,23 +7,11 @@ */ import { uniq } from 'lodash'; +import type { estypes } from '@elastic/elasticsearch'; import { castEsToKbnFieldTypeName } from '../../../../../common'; import { shouldReadFieldFromDocValues } from './should_read_field_from_doc_values'; import { FieldDescriptor } from '../../../fetcher'; -interface FieldCapObject { - type: string; - searchable: boolean; - aggregatable: boolean; - indices?: string[]; - non_searchable_indices?: string[]; - non_aggregatable_indices?: string[]; -} - -export interface FieldCapsResponse { - fields: Record>; -} - /** * Read the response from the _field_caps API to determine the type and * "aggregatable"/"searchable" status of each field. @@ -80,7 +68,9 @@ export interface FieldCapsResponse { * @param {FieldCapsResponse} fieldCapsResponse * @return {Array} */ -export function readFieldCapsResponse(fieldCapsResponse: FieldCapsResponse): FieldDescriptor[] { +export function readFieldCapsResponse( + fieldCapsResponse: estypes.FieldCapabilitiesResponse +): FieldDescriptor[] { const capsByNameThenType = fieldCapsResponse.fields; const kibanaFormattedCaps = Object.keys(capsByNameThenType).reduce<{ diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/index.ts b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/index.ts index d7150d81e3803..773a615727ee5 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/index.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/index.ts @@ -7,5 +7,4 @@ */ export { getFieldCapabilities } from './field_capabilities'; -export { FieldCapsResponse } from './field_caps_response'; export { shouldReadFieldFromDocValues } from './should_read_field_from_doc_values'; diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/resolve_time_pattern.ts b/src/plugins/data/server/index_patterns/fetcher/lib/resolve_time_pattern.ts index d01f74429c3a5..32b9d8c7f893f 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/resolve_time_pattern.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/resolve_time_pattern.ts @@ -12,7 +12,7 @@ import moment from 'moment'; import { ElasticsearchClient } from 'kibana/server'; import { timePatternToWildcard } from './time_pattern_to_wildcard'; -import { callIndexAliasApi, IndicesAliasResponse } from './es_api'; +import { callIndexAliasApi } from './es_api'; /** * Convert a time pattern into a list of indexes it could @@ -28,7 +28,7 @@ import { callIndexAliasApi, IndicesAliasResponse } from './es_api'; export async function resolveTimePattern(callCluster: ElasticsearchClient, timePattern: string) { const aliases = await callIndexAliasApi(callCluster, timePatternToWildcard(timePattern)); - const allIndexDetails = chain(aliases.body) + const allIndexDetails = chain(aliases.body) .reduce( (acc: string[], index: any, indexName: string) => acc.concat(indexName, Object.keys(index.aliases || {})), diff --git a/src/plugins/data/server/index_patterns/index.ts b/src/plugins/data/server/index_patterns/index.ts index 85610cd85a3ce..7226d6f015cf8 100644 --- a/src/plugins/data/server/index_patterns/index.ts +++ b/src/plugins/data/server/index_patterns/index.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import { IndexPatternsService } from '../../common/index_patterns'; - export * from './utils'; export { IndexPatternsFetcher, @@ -17,5 +15,3 @@ export { getCapabilitiesForRollupIndices, } from './fetcher'; export { IndexPatternsServiceProvider, IndexPatternsServiceStart } from './index_patterns_service'; - -export type IndexPatternsHandlerContext = IndexPatternsService; diff --git a/src/plugins/data/server/index_patterns/index_patterns_service.ts b/src/plugins/data/server/index_patterns/index_patterns_service.ts index b489c29bc3b70..5d703021b94da 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -25,7 +25,6 @@ import { getIndexPatternLoad } from './expressions'; import { UiSettingsServerToCommon } from './ui_settings_wrapper'; import { IndexPatternsApiServer } from './index_patterns_api_client'; import { SavedObjectsClientServerToCommon } from './saved_objects_client_wrapper'; -import { DataRequestHandlerContext } from '../types'; export interface IndexPatternsServiceStart { indexPatternsServiceFactory: ( @@ -36,7 +35,6 @@ export interface IndexPatternsServiceStart { export interface IndexPatternsServiceSetupDeps { expressions: ExpressionsServerSetup; - logger: Logger; } export interface IndexPatternsServiceStartDeps { @@ -47,27 +45,11 @@ export interface IndexPatternsServiceStartDeps { export class IndexPatternsServiceProvider implements Plugin { public setup( core: CoreSetup, - { logger, expressions }: IndexPatternsServiceSetupDeps + { expressions }: IndexPatternsServiceSetupDeps ) { core.savedObjects.registerType(indexPatternSavedObjectType); core.capabilities.registerProvider(capabilitiesProvider); - core.http.registerRouteHandlerContext( - 'indexPatterns', - async (context, request) => { - const [coreStart, , dataStart] = await core.getStartServices(); - try { - return await dataStart.indexPatterns.indexPatternsServiceFactory( - coreStart.savedObjects.getScopedClient(request), - coreStart.elasticsearch.client.asScoped(request).asCurrentUser - ); - } catch (e) { - logger.error(e); - return undefined; - } - } - ); - registerRoutes(core.http, core.getStartServices); expressions.registerFunction(getIndexPatternLoad({ getStartServices: core.getStartServices })); diff --git a/src/plugins/data/server/mocks.ts b/src/plugins/data/server/mocks.ts index c82db7a141403..786dd30dbabd0 100644 --- a/src/plugins/data/server/mocks.ts +++ b/src/plugins/data/server/mocks.ts @@ -13,7 +13,7 @@ import { } from './search/mocks'; import { createFieldFormatsSetupMock, createFieldFormatsStartMock } from './field_formats/mocks'; import { createIndexPatternsStartMock } from './index_patterns/mocks'; -import { DataRequestHandlerContext } from './types'; +import { DataRequestHandlerContext } from './search'; function createSetupContract() { return { diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index 3408c39cbb8e2..a7a7663d6981c 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -82,10 +82,7 @@ export class DataServerPlugin this.queryService.setup(core); this.autocompleteService.setup(core); this.kqlTelemetryService.setup(core, { usageCollection }); - this.indexPatterns.setup(core, { - expressions, - logger: this.logger.get('indexPatterns'), - }); + this.indexPatterns.setup(core, { expressions }); core.uiSettings.register(getUiSettings()); diff --git a/src/plugins/data/server/search/collectors/fetch.ts b/src/plugins/data/server/search/collectors/fetch.ts index 6dfc29e2cf2a6..aed35d73c7eb6 100644 --- a/src/plugins/data/server/search/collectors/fetch.ts +++ b/src/plugins/data/server/search/collectors/fetch.ts @@ -9,18 +9,16 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { SharedGlobalConfig } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { CollectorFetchContext } from 'src/plugins/usage_collection/server'; import { CollectedUsage, ReportedUsage } from './register'; interface SearchTelemetry { 'search-telemetry': CollectedUsage; } -type ESResponse = SearchResponse; export function fetchProvider(config$: Observable) { return async ({ esClient }: CollectorFetchContext): Promise => { const config = await config$.pipe(first()).toPromise(); - const { body: esResponse } = await esClient.search( + const { body: esResponse } = await esClient.search( { index: config.kibana.index, body: { @@ -37,7 +35,7 @@ export function fetchProvider(config$: Observable) { averageDuration: null, }; } - const { successCount, errorCount, totalDuration } = esResponse.hits.hits[0]._source[ + const { successCount, errorCount, totalDuration } = esResponse.hits.hits[0]._source![ 'search-telemetry' ]; const averageDuration = totalDuration / successCount; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index cc81dce94c4ec..1afe627545248 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -8,7 +8,6 @@ import { from, Observable } from 'rxjs'; import { first, tap } from 'rxjs/operators'; -import type { SearchResponse } from 'elasticsearch'; import type { Logger, SharedGlobalConfig } from 'kibana/server'; import type { ISearchStrategy } from '../types'; import type { SearchUsage } from '../collectors'; @@ -44,7 +43,7 @@ export const esSearchStrategyProvider = ( ...getShardTimeout(config), ...request.params, }; - const promise = esClient.asCurrentUser.search>(params); + const promise = esClient.asCurrentUser.search(params); const { body } = await shimAbortSignal(promise, abortSignal); const response = shimHitsTotal(body, options); return toKibanaSearchResponse(response); diff --git a/src/plugins/data/server/search/es_search/response_utils.ts b/src/plugins/data/server/search/es_search/response_utils.ts index 975ce392656b1..3bee63624ef67 100644 --- a/src/plugins/data/server/search/es_search/response_utils.ts +++ b/src/plugins/data/server/search/es_search/response_utils.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ISearchOptions } from '../../../common'; /** @@ -14,7 +14,7 @@ import { ISearchOptions } from '../../../common'; * not included as it is already included in `successful`. * @internal */ -export function getTotalLoaded(response: SearchResponse) { +export function getTotalLoaded(response: estypes.SearchResponse) { const { total, failed, successful } = response._shards; const loaded = failed + successful; return { total, loaded }; @@ -24,7 +24,7 @@ export function getTotalLoaded(response: SearchResponse) { * Get the Kibana representation of this response (see `IKibanaSearchResponse`). * @internal */ -export function toKibanaSearchResponse(rawResponse: SearchResponse) { +export function toKibanaSearchResponse(rawResponse: estypes.SearchResponse) { return { rawResponse, isPartial: false, @@ -41,7 +41,7 @@ export function toKibanaSearchResponse(rawResponse: SearchResponse) { * @internal */ export function shimHitsTotal( - response: SearchResponse, + response: estypes.SearchResponse, { legacyHitsTotal = true }: ISearchOptions = {} ) { if (!legacyHitsTotal) return response; diff --git a/src/plugins/data/server/search/expressions/kibana_context.ts b/src/plugins/data/server/search/expressions/kibana_context.ts new file mode 100644 index 0000000000000..c8fdbf4764b0e --- /dev/null +++ b/src/plugins/data/server/search/expressions/kibana_context.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 { StartServicesAccessor } from 'src/core/server'; +import { getKibanaContextFn } from '../../../common/search/expressions'; +import { DataPluginStart, DataPluginStartDependencies } from '../../plugin'; +import { SavedObjectsClientCommon } from '../../../common/index_patterns'; + +/** + * This is some glue code that takes in `core.getStartServices`, extracts the dependencies + * needed for this function, and wraps them behind a `getStartDependencies` function that + * is then called at runtime. + * + * We do this so that we can be explicit about exactly which dependencies the function + * requires, without cluttering up the top-level `plugin.ts` with this logic. It also + * makes testing the expression function a bit easier since `getStartDependencies` is + * the only thing you should need to mock. + * + * @param getStartServices - core's StartServicesAccessor for this plugin + * + * @internal + */ +export function getKibanaContext({ + getStartServices, +}: { + getStartServices: StartServicesAccessor; +}) { + return getKibanaContextFn(async (getKibanaRequest) => { + const request = getKibanaRequest && getKibanaRequest(); + if (!request) { + throw new Error('KIBANA_CONTEXT_KIBANA_REQUEST_MISSING'); + } + + const [{ savedObjects }] = await getStartServices(); + return { + savedObjectsClient: (savedObjects.getScopedClient( + request + ) as any) as SavedObjectsClientCommon, + }; + }); +} diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 8648d3f4526fe..0c238adf831bd 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -8,7 +8,6 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; @@ -66,6 +65,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { try { const promise = esClient.asCurrentUser.msearch( { + // @ts-expect-error @elastic/elasticsearch client types don't support plain string bodies body: convertRequestBody(params.body, timeout), }, { @@ -78,9 +78,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { body: { ...response, body: { - responses: response.body.responses?.map((r: SearchResponse) => - shimHitsTotal(r) - ), + responses: response.body.responses?.map((r) => shimHitsTotal(r)), }, }, }; diff --git a/src/plugins/data/server/search/routes/msearch.ts b/src/plugins/data/server/search/routes/msearch.ts index b5f06c4b343e7..b578805d8c2df 100644 --- a/src/plugins/data/server/search/routes/msearch.ts +++ b/src/plugins/data/server/search/routes/msearch.ts @@ -12,7 +12,7 @@ import { SearchRouteDependencies } from '../search_service'; import { getCallMsearch } from './call_msearch'; import { reportServerError } from '../../../../kibana_utils/server'; -import type { DataPluginRouter } from '../../types'; +import type { DataPluginRouter } from '../types'; /** * The msearch route takes in an array of searches, each consisting of header * and body json, and reformts them into a single request for the _msearch API. diff --git a/src/plugins/data/server/search/routes/search.ts b/src/plugins/data/server/search/routes/search.ts index 6690e2b81f3e4..1680a9c4a7237 100644 --- a/src/plugins/data/server/search/routes/search.ts +++ b/src/plugins/data/server/search/routes/search.ts @@ -10,7 +10,7 @@ import { first } from 'rxjs/operators'; import { schema } from '@kbn/config-schema'; import { getRequestAbortedSignal } from '../../lib'; import { reportServerError } from '../../../../kibana_utils/server'; -import type { DataPluginRouter } from '../../types'; +import type { DataPluginRouter } from '../types'; export function registerSearchRoute(router: DataPluginRouter): void { router.post( diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 69710e82b73b4..fdf0b66197b34 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -29,6 +29,7 @@ import type { ISearchStrategy, SearchEnhancements, SearchStrategyDependencies, + DataRequestHandlerContext, } from './types'; import { AggsService } from './aggs'; @@ -52,7 +53,6 @@ import { ISearchOptions, kibana, kibanaContext, - kibanaContextFunction, kibanaTimerangeFunction, kibanaFilterFunction, kqlFunction, @@ -74,7 +74,7 @@ import { ConfigSchema } from '../../config'; import { ISearchSessionService, SearchSessionService } from './session'; import { KbnServerError } from '../../../kibana_utils/server'; import { registerBsearchRoute } from './routes/bsearch'; -import { DataRequestHandlerContext } from '../types'; +import { getKibanaContext } from './expressions/kibana_context'; type StrategyMap = Record>; @@ -154,7 +154,7 @@ export class SearchService implements Plugin { expressions.registerFunction(luceneFunction); expressions.registerFunction(kqlFunction); expressions.registerFunction(kibanaTimerangeFunction); - expressions.registerFunction(kibanaContextFunction); + expressions.registerFunction(getKibanaContext({ getStartServices: core.getStartServices })); expressions.registerFunction(fieldFunction); expressions.registerFunction(rangeFunction); expressions.registerFunction(kibanaFilterFunction); diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index d7aadcc348c87..e8548257c0167 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -8,10 +8,12 @@ import { Observable } from 'rxjs'; import type { + IRouter, IScopedClusterClient, IUiSettingsClient, SavedObjectsClientContract, KibanaRequest, + RequestHandlerContext, } from 'src/core/server'; import { ISearchOptions, @@ -114,3 +116,12 @@ export interface ISearchStart< } export type SearchRequestHandlerContext = IScopedSearchClient; + +/** + * @internal + */ +export interface DataRequestHandlerContext extends RequestHandlerContext { + search: SearchRequestHandlerContext; +} + +export type DataPluginRouter = IRouter; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 83f7c67eba057..12458d7a74d9f 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -25,6 +25,7 @@ import { ElasticsearchClient as ElasticsearchClient_2 } from 'kibana/server'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; import { ErrorToastOptions } from 'src/core/public/notifications'; +import { estypes } from '@elastic/elasticsearch'; import { ExecutionContext } from 'src/plugins/expressions/common'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; @@ -64,7 +65,6 @@ import { SavedObjectsFindOptions } from 'kibana/server'; import { SavedObjectsFindResponse } from 'kibana/server'; import { SavedObjectsUpdateResponse } from 'kibana/server'; import { Search } from '@elastic/elasticsearch/api/requestParams'; -import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; import { SharedGlobalConfig as SharedGlobalConfig_2 } from 'kibana/server'; import { ToastInputFields } from 'src/core/public/notifications'; @@ -134,6 +134,10 @@ export interface AggFunctionsMapping { // // (undocumented) aggFilter: ReturnType; + // Warning: (ae-forgotten-export) The symbol "aggFilteredMetric" needs to be exported by the entry point index.d.ts + // + // (undocumented) + aggFilteredMetric: ReturnType; // Warning: (ae-forgotten-export) The symbol "aggFilters" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -312,12 +316,6 @@ export const config: PluginConfigDescriptor; // @internal (undocumented) export interface DataRequestHandlerContext extends RequestHandlerContext { - // Warning: (ae-forgotten-export) The symbol "IndexPatternsHandlerContext" needs to be exported by the entry point index.d.ts - // - // (undocumented) - indexPatterns?: IndexPatternsHandlerContext; - // Warning: (ae-forgotten-export) The symbol "SearchRequestHandlerContext" needs to be exported by the entry point index.d.ts - // // (undocumented) search: SearchRequestHandlerContext; } @@ -405,7 +403,7 @@ export const ES_SEARCH_STRATEGY = "es"; // Warning: (ae-missing-release-tag) "EsaggsExpressionFunctionDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'esaggs', Input_34, Arguments_20, Output_34>; +export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'esaggs', Input_35, Arguments_21, Output_35>; // Warning: (ae-missing-release-tag) "esFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -485,7 +483,7 @@ export type ExpressionFunctionKibana = ExpressionFunctionDefinition<'kibana', Ex // Warning: (ae-missing-release-tag) "ExpressionFunctionKibanaContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type ExpressionFunctionKibanaContext = ExpressionFunctionDefinition<'kibana_context', KibanaContext | null, Arguments_21, Promise, ExecutionContext>; +export type ExpressionFunctionKibanaContext = ExpressionFunctionDefinition<'kibana_context', KibanaContext | null, Arguments_22, Promise, ExecutionContext>; // Warning: (ae-missing-release-tag) "ExpressionValueSearchContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -598,7 +596,7 @@ export function getTime(indexPattern: IIndexPattern | undefined, timeRange: Time }): import("../..").RangeFilter | undefined; // @internal -export function getTotalLoaded(response: SearchResponse): { +export function getTotalLoaded(response: estypes.SearchResponse): { total: number; loaded: number; }; @@ -633,7 +631,7 @@ export interface IEsSearchRequest extends IKibanaSearchRequest = IKibanaSearchResponse>; +export type IEsSearchResponse = IKibanaSearchResponse>; // Warning: (ae-missing-release-tag) "IFieldFormatsRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -960,7 +958,7 @@ export class IndexPatternsServiceProvider implements Plugin_3, { logger, expressions }: IndexPatternsServiceSetupDeps): void; + setup(core: CoreSetup_2, { expressions }: IndexPatternsServiceSetupDeps): void; // Warning: (ae-forgotten-export) The symbol "IndexPatternsServiceStartDeps" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1141,6 +1139,8 @@ export enum METRIC_TYPES { // (undocumented) DERIVATIVE = "derivative", // (undocumented) + FILTERED_METRIC = "filtered_metric", + // (undocumented) GEO_BOUNDS = "geo_bounds", // (undocumented) GEO_CENTROID = "geo_centroid", @@ -1226,7 +1226,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -1325,6 +1325,11 @@ export const search: { tabifyGetColumns: typeof tabifyGetColumns; }; +// Warning: (ae-missing-release-tag) "SearchRequestHandlerContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SearchRequestHandlerContext = IScopedSearchClient; + // @internal export class SearchSessionService implements ISearchSessionService { constructor(); @@ -1379,30 +1384,26 @@ export function searchUsageObserver(logger: Logger_2, usage?: SearchUsage, { isR export const shimAbortSignal: (promise: TransportRequestPromise, signal?: AbortSignal | undefined) => TransportRequestPromise; // @internal -export function shimHitsTotal(response: SearchResponse, { legacyHitsTotal }?: ISearchOptions): { +export function shimHitsTotal(response: estypes.SearchResponse, { legacyHitsTotal }?: ISearchOptions): { hits: { total: any; - max_score: number; - hits: { - _index: string; - _type: string; - _id: string; - _score: number; - _source: unknown; - _version?: number | undefined; - _explanation?: import("elasticsearch").Explanation | undefined; - fields?: any; - highlight?: any; - inner_hits?: any; - matched_queries?: string[] | undefined; - sort?: string[] | undefined; - }[]; + hits: estypes.Hit[]; + max_score?: number | undefined; }; took: number; timed_out: boolean; + _shards: estypes.ShardStatistics; + aggregations?: Record | undefined; + _clusters?: estypes.ClusterStatistics | undefined; + documents?: unknown[] | undefined; + fields?: Record | undefined; + max_score?: number | undefined; + num_reduce_phases?: number | undefined; + profile?: estypes.Profile | undefined; + pit_id?: string | undefined; _scroll_id?: string | undefined; - _shards: import("elasticsearch").ShardsResponse; - aggregations?: any; + suggest?: Record[]> | undefined; + terminated_early?: boolean | undefined; }; // Warning: (ae-missing-release-tag) "shouldReadFieldFromDocValues" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1420,10 +1421,10 @@ export type TimeRange = { }; // @internal -export function toKibanaSearchResponse(rawResponse: SearchResponse): { +export function toKibanaSearchResponse(rawResponse: estypes.SearchResponse): { total: number; loaded: number; - rawResponse: SearchResponse; + rawResponse: estypes.SearchResponse; isPartial: boolean; isRunning: boolean; }; @@ -1516,7 +1517,7 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:270:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:79:74 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/search/types.ts:112:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/search/types.ts:114:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/types.ts b/src/plugins/data/server/types.ts deleted file mode 100644 index ea0fa49058d37..0000000000000 --- a/src/plugins/data/server/types.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 and the Server Side Public License, v 1; you may not 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 { IRouter, RequestHandlerContext } from 'src/core/server'; - -import { SearchRequestHandlerContext } from './search'; -import { IndexPatternsHandlerContext } from './index_patterns'; - -/** - * @internal - */ -export interface DataRequestHandlerContext extends RequestHandlerContext { - search: SearchRequestHandlerContext; - indexPatterns?: IndexPatternsHandlerContext; -} - -export type DataPluginRouter = IRouter; diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 21560b1328840..9c95878af631e 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -7,7 +7,14 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"], + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "config.ts", + "common/**/*.json", + "public/**/*.json" + ], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../bfetch/tsconfig.json" }, @@ -16,6 +23,6 @@ { "path": "../inspector/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, - { "path": "../kibana_react/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" } ] } diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index b53e5328f21ba..ad84518af9de3 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -22,6 +22,8 @@ const fields = [ type: 'date', scripted: false, filterable: true, + aggregatable: true, + sortable: true, }, { name: 'message', @@ -34,12 +36,14 @@ const fields = [ type: 'string', scripted: false, filterable: true, + aggregatable: true, }, { name: 'bytes', type: 'number', scripted: false, filterable: true, + aggregatable: true, }, { name: 'scripted', @@ -55,14 +59,14 @@ fields.getByName = (name: string) => { const indexPattern = ({ id: 'index-pattern-with-timefield-id', - title: 'index-pattern-without-timefield', + title: 'index-pattern-with-timefield', metaFields: ['_index', '_score'], flattenHit: undefined, formatHit: jest.fn((hit) => hit._source), fields, getComputedFields: () => ({}), getSourceFiltering: () => ({}), - getFieldByName: () => ({}), + getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', } as unknown) as IndexPattern; diff --git a/src/plugins/discover/public/__mocks__/ui_settings.ts b/src/plugins/discover/public/__mocks__/ui_settings.ts index 8bc6de1b9ca41..e021a39a568e9 100644 --- a/src/plugins/discover/public/__mocks__/ui_settings.ts +++ b/src/plugins/discover/public/__mocks__/ui_settings.ts @@ -7,12 +7,14 @@ */ import { IUiSettingsClient } from 'kibana/public'; -import { SAMPLE_SIZE_SETTING } from '../../common'; +import { DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING } from '../../common'; export const uiSettingsMock = ({ get: (key: string) => { if (key === SAMPLE_SIZE_SETTING) { return 10; + } else if (key === DEFAULT_COLUMNS_SETTING) { + return ['default_column']; } }, } as unknown) as IUiSettingsClient; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4a761f2fefa65..2c80fc111c740 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -9,8 +9,6 @@ import _ from 'lodash'; import { merge, Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; -import moment from 'moment'; -import dateMath from '@elastic/datemath'; import { i18n } from '@kbn/i18n'; import { createSearchSessionRestorationDataProvider, getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; @@ -23,7 +21,6 @@ import { } from '../../../../data/public'; import { getSortArray } from './doc_table'; import indexTemplateLegacy from './discover_legacy.html'; -import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; import { discoverResponseHandler } from './response_handler'; import { getAngularModule, @@ -36,25 +33,22 @@ import { subscribeWithScope, tabifyAggResponse, } from '../../kibana_services'; -import { - getRootBreadcrumbs, - getSavedSearchBreadcrumbs, - setBreadcrumbsTitle, -} from '../helpers/breadcrumbs'; +import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs'; +import { getStateDefaults } from '../helpers/get_state_defaults'; +import { getResultState } from '../helpers/get_result_state'; import { validateTimeRange } from '../helpers/validate_time_range'; import { addFatalError } from '../../../../kibana_legacy/public'; import { - DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, SEARCH_ON_PAGE_LOAD_SETTING, - SORT_DEFAULT_ORDER_SETTING, } from '../../../common'; import { loadIndexPattern, resolveIndexPattern } from '../helpers/resolve_index_pattern'; import { updateSearchSource } from '../helpers/update_search_source'; import { calcFieldCounts } from '../helpers/calc_field_counts'; -import { getDefaultSort } from './doc_table/lib/get_default_sort'; import { DiscoverSearchSessionManager } from './discover_search_session'; +import { applyAggsToSearchSource, getDimensions } from '../components/histogram'; +import { fetchStatuses } from '../components/constants'; const services = getServices(); @@ -70,13 +64,6 @@ const { uiSettings: config, } = getServices(); -const fetchStatuses = { - UNINITIALIZED: 'uninitialized', - LOADING: 'loading', - COMPLETE: 'complete', - ERROR: 'error', -}; - const app = getAngularModule(); app.config(($routeProvider) => { @@ -161,7 +148,7 @@ app.directive('discoverApp', function () { }; }); -function discoverController($route, $scope, Promise) { +function discoverController($route, $scope) { const { isDefault: isDefaultType } = indexPatternsUtils; const subscriptions = new Subscription(); const refetch$ = new Subject(); @@ -191,7 +178,14 @@ function discoverController($route, $scope, Promise) { }); const stateContainer = getState({ - getStateDefaults, + getStateDefaults: () => + getStateDefaults({ + config, + data, + indexPattern: $scope.indexPattern, + savedSearch, + searchSource: persistentSearchSource, + }), storeInSessionStorage: config.get('state:storeInSessionStorage'), history, toasts: core.notifications.toasts, @@ -232,6 +226,21 @@ function discoverController($route, $scope, Promise) { query: true, } ); + const showUnmappedFields = $scope.useNewFieldsApi; + const updateSearchSourceHelper = () => { + const { indexPattern, useNewFieldsApi } = $scope; + const { columns, sort } = $scope.state; + updateSearchSource({ + persistentSearchSource, + volatileSearchSource: $scope.volatileSearchSource, + indexPattern, + services, + sort, + columns, + useNewFieldsApi, + showUnmappedFields, + }); + }; const appStateUnsubscribe = appStateContainer.subscribe(async (newState) => { const { state: newStatePartial } = splitState(newState); @@ -293,21 +302,6 @@ function discoverController($route, $scope, Promise) { } ); - // update data source when filters update - subscriptions.add( - subscribeWithScope( - $scope, - filterManager.getUpdates$(), - { - next: () => { - $scope.state.filters = filterManager.getAppFilters(); - $scope.updateDataSource(); - }, - }, - (error) => addFatalError(core.fatalErrors, error) - ) - ); - $scope.opts = { // number of records to fetch, then paginate through sampleSize: config.get(SAMPLE_SIZE_SETTING), @@ -329,8 +323,19 @@ function discoverController($route, $scope, Promise) { requests: new RequestAdapter(), }); - $scope.minimumVisibleRows = 50; + const shouldSearchOnPageLoad = () => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + return ( + config.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch.id !== undefined || + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL() + ); + }; + $scope.fetchStatus = fetchStatuses.UNINITIALIZED; + $scope.resultState = shouldSearchOnPageLoad() ? 'loading' : 'uninitialized'; let abortController; $scope.$on('$destroy', () => { @@ -385,157 +390,12 @@ function discoverController($route, $scope, Promise) { volatileSearchSource.setParent(persistentSearchSource); $scope.volatileSearchSource = volatileSearchSource; - - const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; - chrome.docTitle.change(`Discover${pageTitleSuffix}`); - - setBreadcrumbsTitle(savedSearch, chrome); - - function getDefaultColumns() { - if (savedSearch.columns.length > 0) { - return [...savedSearch.columns]; - } - return [...config.get(DEFAULT_COLUMNS_SETTING)]; - } - - function getStateDefaults() { - const query = - persistentSearchSource.getField('query') || data.query.queryString.getDefaultQuery(); - const sort = getSortArray(savedSearch.sort, $scope.indexPattern); - const columns = getDefaultColumns(); - - const defaultState = { - query, - sort: !sort.length - ? getDefaultSort($scope.indexPattern, config.get(SORT_DEFAULT_ORDER_SETTING, 'desc')) - : sort, - columns, - index: $scope.indexPattern.id, - interval: 'auto', - filters: _.cloneDeep(persistentSearchSource.getOwnField('filter')), - }; - if (savedSearch.grid) { - defaultState.grid = savedSearch.grid; - } - if (savedSearch.hideChart) { - defaultState.hideChart = savedSearch.hideChart; - } - - return defaultState; - } - $scope.state.index = $scope.indexPattern.id; $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); - const shouldSearchOnPageLoad = () => { - // A saved search is created on every page load, so we check the ID to see if we're loading a - // previously saved search or if it is just transient - return ( - config.get(SEARCH_ON_PAGE_LOAD_SETTING) || - savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false || - searchSessionManager.hasSearchSessionIdInURL() - ); - }; - - const init = _.once(() => { - $scope.updateDataSource().then(async () => { - const fetch$ = merge( - refetch$, - filterManager.getFetches$(), - timefilter.getFetch$(), - timefilter.getAutoRefreshFetch$(), - data.query.queryString.getUpdates$(), - searchSessionManager.newSearchSessionIdFromURL$ - ).pipe(debounceTime(100)); - - subscriptions.add( - subscribeWithScope( - $scope, - fetch$, - { - next: $scope.fetch, - }, - (error) => addFatalError(core.fatalErrors, error) - ) - ); - subscriptions.add( - subscribeWithScope( - $scope, - timefilter.getTimeUpdate$(), - { - next: () => { - $scope.updateTime(); - }, - }, - (error) => addFatalError(core.fatalErrors, error) - ) - ); - - $scope.$watchMulti( - ['rows', 'fetchStatus'], - (function updateResultState() { - let prev = {}; - const status = { - UNINITIALIZED: 'uninitialized', - LOADING: 'loading', // initial data load - READY: 'ready', // results came back - NO_RESULTS: 'none', // no results came back - }; - - function pick(rows, oldRows, fetchStatus) { - // initial state, pretend we're already loading if we're about to execute a search so - // that the uninitilized message doesn't flash on screen - if (!$scope.fetchError && rows == null && oldRows == null && shouldSearchOnPageLoad()) { - return status.LOADING; - } - - if (fetchStatus === fetchStatuses.UNINITIALIZED) { - return status.UNINITIALIZED; - } - - const rowsEmpty = _.isEmpty(rows); - if (rowsEmpty && fetchStatus === fetchStatuses.LOADING) return status.LOADING; - else if (!rowsEmpty) return status.READY; - else return status.NO_RESULTS; - } - - return function () { - const current = { - rows: $scope.rows, - fetchStatus: $scope.fetchStatus, - }; - - $scope.resultState = pick( - current.rows, - prev.rows, - current.fetchStatus, - prev.fetchStatus - ); - - prev = current; - }; - })() - ); - - if (getTimeField()) { - setupVisualization(); - $scope.updateTime(); - } - - init.complete = true; - if (shouldSearchOnPageLoad()) { - refetch$.next(); - } - }); - }); - $scope.opts.fetch = $scope.fetch = function () { - // ignore requests to fetch before the app inits - if (!init.complete) return; $scope.fetchCounter++; $scope.fetchError = undefined; - $scope.minimumVisibleRows = 50; if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { $scope.resultState = 'none'; return; @@ -546,17 +406,23 @@ function discoverController($route, $scope, Promise) { abortController = new AbortController(); const searchSessionId = searchSessionManager.getNextSearchSessionId(); + updateSearchSourceHelper(); - $scope - .updateDataSource() - .then(setupVisualization) - .then(function () { - $scope.fetchStatus = fetchStatuses.LOADING; - logInspectorRequest({ searchSessionId }); - return $scope.volatileSearchSource.fetch({ - abortSignal: abortController.signal, - sessionId: searchSessionId, - }); + $scope.opts.chartAggConfigs = applyAggsToSearchSource( + getTimeField() && !$scope.state.hideChart, + volatileSearchSource, + $scope.state.interval, + $scope.indexPattern, + data + ); + + $scope.fetchStatus = fetchStatuses.LOADING; + $scope.resultState = getResultState($scope.fetchStatus, $scope.rows); + logInspectorRequest({ searchSessionId }); + return $scope.volatileSearchSource + .fetch({ + abortSignal: abortController.signal, + sessionId: searchSessionId, }) .then(onResults) .catch((error) => { @@ -565,40 +431,14 @@ function discoverController($route, $scope, Promise) { $scope.fetchStatus = fetchStatuses.NO_RESULTS; $scope.fetchError = error; - data.search.showError(error); + }) + .finally(() => { + $scope.resultState = getResultState($scope.fetchStatus, $scope.rows); + $scope.$apply(); }); }; - function getDimensions(aggs, timeRange) { - const [metric, agg] = aggs; - agg.params.timeRange = timeRange; - const bounds = agg.params.timeRange ? timefilter.calculateBounds(agg.params.timeRange) : null; - agg.buckets.setBounds(bounds); - - const { esUnit, esValue } = agg.buckets.getInterval(); - return { - x: { - accessor: 0, - label: agg.makeLabel(), - format: agg.toSerializedFieldFormat(), - params: { - date: true, - interval: moment.duration(esValue, esUnit), - intervalESValue: esValue, - intervalESUnit: esUnit, - format: agg.buckets.getScaledDateFormat(), - bounds: agg.buckets.getBounds(), - }, - }, - y: { - accessor: 1, - format: metric.toSerializedFieldFormat(), - label: metric.makeLabel(), - }, - }; - } - function onResults(resp) { inspectorRequest .stats(getResponseInspectorStats(resp, $scope.volatileSearchSource)) @@ -607,11 +447,10 @@ function discoverController($route, $scope, Promise) { if (getTimeField() && !$scope.state.hideChart) { const tabifiedData = tabifyAggResponse($scope.opts.chartAggConfigs, resp); $scope.volatileSearchSource.rawResponse = resp; - $scope.histogramData = discoverResponseHandler( - tabifiedData, - getDimensions($scope.opts.chartAggConfigs.aggs, $scope.timeRange) - ); - $scope.updateTime(); + const dimensions = getDimensions($scope.opts.chartAggConfigs, data); + if (dimensions) { + $scope.histogramData = discoverResponseHandler(tabifiedData, dimensions); + } } $scope.hits = resp.hits.total; @@ -640,15 +479,6 @@ function discoverController($route, $scope, Promise) { }); } - $scope.updateTime = function () { - const { from, to } = timefilter.getTime(); - // this is the timerange for the histogram, should be refactored - $scope.timeRange = { - from: dateMath.parse(from), - to: dateMath.parse(to, { roundUp: true }), - }; - }; - $scope.resetQuery = function () { history.push( $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' @@ -656,88 +486,39 @@ function discoverController($route, $scope, Promise) { $route.reload(); }; - $scope.onSkipBottomButtonClick = async () => { - // show all the Rows - $scope.minimumVisibleRows = $scope.hits; - - // delay scrolling to after the rows have been rendered - const bottomMarker = document.getElementById('discoverBottomMarker'); - const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - - while ($scope.rows.length !== document.getElementsByClassName('kbnDocTable__row').length) { - await wait(50); - } - bottomMarker.focus(); - await wait(50); - bottomMarker.blur(); - }; - $scope.newQuery = function () { history.push('/'); }; - const showUnmappedFields = $scope.useNewFieldsApi; - $scope.unmappedFieldsConfig = { showUnmappedFields, }; - $scope.updateDataSource = () => { - const { indexPattern, useNewFieldsApi } = $scope; - const { columns, sort } = $scope.state; - updateSearchSource({ - persistentSearchSource, - volatileSearchSource: $scope.volatileSearchSource, - indexPattern, - services, - sort, - columns, - useNewFieldsApi, - showUnmappedFields, - }); - return Promise.resolve(); - }; - - async function setupVisualization() { - // If no timefield has been specified we don't create a histogram of messages - if (!getTimeField() || $scope.state.hideChart) { - if ($scope.volatileSearchSource.getField('aggs')) { - // cleanup aggs field in case it was set before - $scope.volatileSearchSource.removeField('aggs'); - } - return; - } - const { interval: histogramInterval } = $scope.state; + const fetch$ = merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getAutoRefreshFetch$(), + data.query.queryString.getUpdates$(), + searchSessionManager.newSearchSessionIdFromURL$ + ).pipe(debounceTime(100)); - const visStateAggs = [ - { - type: 'count', - schema: 'metric', - }, + subscriptions.add( + subscribeWithScope( + $scope, + fetch$, { - type: 'date_histogram', - schema: 'segment', - params: { - field: getTimeField(), - interval: histogramInterval, - timeRange: timefilter.getTime(), - }, + next: $scope.fetch, }, - ]; - $scope.opts.chartAggConfigs = data.search.aggs.createAggConfigs( - $scope.indexPattern, - visStateAggs - ); - - $scope.volatileSearchSource.setField('aggs', function () { - if (!$scope.opts.chartAggConfigs) return; - return $scope.opts.chartAggConfigs.toDsl(); - }); - } - - addHelpMenuToAppChrome(chrome); + (error) => addFatalError(core.fatalErrors, error) + ) + ); - init(); - // Propagate current app state to url, then start syncing - replaceUrlAppState().then(() => startStateSync()); + // Propagate current app state to url, then start syncing and fetching + replaceUrlAppState().then(() => { + startStateSync(); + if (shouldSearchOnPageLoad()) { + refetch$.next(); + } + }); } diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html index a01f285b1a150..f14800f81d08e 100644 --- a/src/plugins/discover/public/application/angular/discover_legacy.html +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -7,15 +7,12 @@ histogram-data="histogramData" hits="hits" index-pattern="indexPattern" - minimum-visible-rows="minimumVisibleRows" - on-skip-bottom-button-click="onSkipBottomButtonClick" opts="opts" reset-query="resetQuery" result-state="resultState" rows="rows" search-source="volatileSearchSource" state="state" - time-range="timeRange" top-nav-menu="topNavMenu" use-new-fields-api="useNewFieldsApi" unmapped-fields-config="unmappedFieldsConfig" diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx index e768b750aa134..0202f88e0e902 100644 --- a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -8,18 +8,20 @@ import angular, { auto, ICompileService, IScope } from 'angular'; import { render } from 'react-dom'; -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import type { estypes } from '@elastic/elasticsearch'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { getServices, IIndexPattern } from '../../../kibana_services'; import { IndexPatternField } from '../../../../../data/common/index_patterns'; +import { SkipBottomButton } from '../../components/skip_bottom_button'; export interface DocTableLegacyProps { columns: string[]; searchDescription?: string; searchTitle?: string; onFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; - rows: Array>; + rows: estypes.Hit[]; indexPattern: IIndexPattern; minimumVisibleRows: number; onAddColumn?: (column: string) => void; @@ -97,18 +99,42 @@ function getRenderFn(domNode: Element, props: any) { export function DocTableLegacy(renderProps: DocTableLegacyProps) { const ref = useRef(null); const scope = useRef(); + const [rows, setRows] = useState(renderProps.rows); + const [minimumVisibleRows, setMinimumVisibleRows] = useState(50); + const onSkipBottomButtonClick = useCallback(async () => { + // delay scrolling to after the rows have been rendered + const bottomMarker = document.getElementById('discoverBottomMarker'); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + // show all the rows + setMinimumVisibleRows(renderProps.rows.length); + + while (renderProps.rows.length !== document.getElementsByClassName('kbnDocTable__row').length) { + await wait(50); + } + bottomMarker!.focus(); + await wait(50); + bottomMarker!.blur(); + }, [setMinimumVisibleRows, renderProps.rows]); + + useEffect(() => { + if (minimumVisibleRows > 50) { + setMinimumVisibleRows(50); + } + setRows(renderProps.rows); + }, [renderProps.rows, minimumVisibleRows, setMinimumVisibleRows]); useEffect(() => { if (ref && ref.current && !scope.current) { - const fn = getRenderFn(ref.current, renderProps); + const fn = getRenderFn(ref.current, { ...renderProps, rows, minimumVisibleRows }); fn().then((newScope) => { scope.current = newScope; }); } else if (scope && scope.current) { - scope.current.renderProps = renderProps; + scope.current.renderProps = { ...renderProps, rows, minimumVisibleRows }; scope.current.$apply(); } - }, [renderProps]); + }, [renderProps, minimumVisibleRows, rows]); + useEffect(() => { return () => { if (scope.current) { @@ -118,6 +144,7 @@ export function DocTableLegacy(renderProps: DocTableLegacyProps) { }, []); return (

+
{renderProps.rows.length === renderProps.sampleSize ? (
- +
diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts index 4838a4019357c..4b16c1aa3dcc6 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts @@ -14,9 +14,9 @@ export type SortPairArr = [string, string]; export type SortPair = SortPairArr | SortPairObj; export type SortInput = SortPair | SortPair[]; -export function isSortable(fieldName: string, indexPattern: IndexPattern) { +export function isSortable(fieldName: string, indexPattern: IndexPattern): boolean { const field = indexPattern.getFieldByName(fieldName); - return field && field.sortable; + return !!(field && field.sortable); } function createSortObject( diff --git a/src/plugins/discover/public/application/components/constants.ts b/src/plugins/discover/public/application/components/constants.ts new file mode 100644 index 0000000000000..42845e83b7435 --- /dev/null +++ b/src/plugins/discover/public/application/components/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const fetchStatuses = { + UNINITIALIZED: 'uninitialized', + LOADING: 'loading', + COMPLETE: 'complete', + ERROR: 'error', +}; diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx index 8dded3598c279..5031f78c49fcc 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx @@ -87,6 +87,7 @@ export function ContextAppLegacy(renderProps: ContextAppProps) { minimumVisibleRows, useNewFieldsApi, } = renderProps; + // @ts-expect-error doesn't implement full DocTableLegacyProps interface return { columns, indexPattern, diff --git a/src/plugins/discover/public/application/components/create_discover_directive.ts b/src/plugins/discover/public/application/components/create_discover_directive.ts index 8d1360aeaddad..5abf87fdfbc08 100644 --- a/src/plugins/discover/public/application/components/create_discover_directive.ts +++ b/src/plugins/discover/public/application/components/create_discover_directive.ts @@ -16,8 +16,6 @@ export function createDiscoverDirective(reactDirective: any) { ['histogramData', { watchDepth: 'reference' }], ['hits', { watchDepth: 'reference' }], ['indexPattern', { watchDepth: 'reference' }], - ['minimumVisibleRows', { watchDepth: 'reference' }], - ['onSkipBottomButtonClick', { watchDepth: 'reference' }], ['opts', { watchDepth: 'reference' }], ['resetQuery', { watchDepth: 'reference' }], ['resultState', { watchDepth: 'reference' }], @@ -26,7 +24,6 @@ export function createDiscoverDirective(reactDirective: any) { ['searchSource', { watchDepth: 'reference' }], ['showSaveQuery', { watchDepth: 'reference' }], ['state', { watchDepth: 'reference' }], - ['timeRange', { watchDepth: 'reference' }], ['topNavMenu', { watchDepth: 'reference' }], ['updateQuery', { watchDepth: 'reference' }], ['updateSavedQueryId', { watchDepth: 'reference' }], diff --git a/src/plugins/discover/public/application/components/create_discover_grid_directive.tsx b/src/plugins/discover/public/application/components/create_discover_grid_directive.tsx index 0d17fcbba9c23..d55e46574e1a7 100644 --- a/src/plugins/discover/public/application/components/create_discover_grid_directive.tsx +++ b/src/plugins/discover/public/application/components/create_discover_grid_directive.tsx @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import React, { useState } from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { DiscoverGrid, DiscoverGridProps } from './discover_grid/discover_grid'; @@ -38,6 +37,7 @@ export function createDiscoverGridDirective(reactDirective: any) { return reactDirective(DiscoverGridEmbeddable, [ ['columns', { watchDepth: 'collection' }], ['indexPattern', { watchDepth: 'reference' }], + ['isLoading', { watchDepth: 'reference' }], ['onAddColumn', { watchDepth: 'reference', wrapApply: false }], ['onFilter', { watchDepth: 'reference', wrapApply: false }], ['onRemoveColumn', { watchDepth: 'reference', wrapApply: false }], diff --git a/src/plugins/discover/public/application/components/discover.test.tsx b/src/plugins/discover/public/application/components/discover.test.tsx index 00554196e11fd..d804d60870421 100644 --- a/src/plugins/discover/public/application/components/discover.test.tsx +++ b/src/plugins/discover/public/application/components/discover.test.tsx @@ -62,6 +62,7 @@ function getProps(indexPattern: IndexPattern): DiscoverProps { fetch: jest.fn(), fetchCounter: 0, fetchError: undefined, + fetchStatus: 'loading', fieldCounts: calcFieldCounts({}, esHits, indexPattern), hits: esHits.length, indexPattern, diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 1c4a5be2f2b24..9615a1c10ea8e 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import './discover.scss'; -import React, { useState, useRef, useMemo, useCallback } from 'react'; +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; import { EuiButtonEmpty, EuiButtonIcon, @@ -30,7 +30,6 @@ import { DiscoverHistogram, DiscoverUninitialized } from '../angular/directives' import { DiscoverNoResults } from './no_results'; import { LoadingSpinner } from './loading_spinner/loading_spinner'; import { DocTableLegacy } from '../angular/doc_table/create_doc_table_react'; -import { SkipBottomButton } from './skip_bottom_button'; import { esFilters, IndexPatternField, search } from '../../../../data/public'; import { DiscoverSidebarResponsive } from './sidebar'; import { DiscoverProps } from './types'; @@ -42,29 +41,32 @@ import { DocViewFilterFn } from '../doc_views/doc_views_types'; import { DiscoverGrid } from './discover_grid/discover_grid'; import { DiscoverTopNav } from './discover_topnav'; import { ElasticSearchHit } from '../doc_views/doc_views_types'; +import { setBreadcrumbsTitle } from '../helpers/breadcrumbs'; +import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; const DocTableLegacyMemoized = React.memo(DocTableLegacy); const SidebarMemoized = React.memo(DiscoverSidebarResponsive); const DataGridMemoized = React.memo(DiscoverGrid); const TopNavMemoized = React.memo(DiscoverTopNav); +const TimechartHeaderMemoized = React.memo(TimechartHeader); +const DiscoverHistogramMemoized = React.memo(DiscoverHistogram); export function Discover({ fetch, fetchCounter, fetchError, fieldCounts, + fetchStatus, histogramData, hits, indexPattern, minimumVisibleRows, - onSkipBottomButtonClick, opts, resetQuery, resultState, rows, searchSource, state, - timeRange, unmappedFieldsConfig, }: DiscoverProps) { const [expandedDoc, setExpandedDoc] = useState(undefined); @@ -80,13 +82,16 @@ export function Discover({ }, [state, opts]); const hideChart = useMemo(() => state.hideChart, [state]); const { savedSearch, indexPatternList, config, services, data, setAppState } = opts; - const { trackUiMetric, capabilities, indexPatterns } = services; + const { trackUiMetric, capabilities, indexPatterns, chrome, docLinks } = services; + const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const bucketAggConfig = opts.chartAggConfigs?.aggs[1]; - const bucketInterval = - bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) + const bucketInterval = useMemo(() => { + const bucketAggConfig = opts.chartAggConfigs?.aggs[1]; + return bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) ? bucketAggConfig.buckets?.getInterval() : undefined; + }, [opts.chartAggConfigs]); + const contentCentered = resultState === 'uninitialized'; const isLegacy = services.uiSettings.get('doc_table:legacy'); const useNewFieldsApi = !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); @@ -100,6 +105,14 @@ export function Discover({ [opts] ); + useEffect(() => { + const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; + chrome.docTitle.change(`Discover${pageTitleSuffix}`); + + setBreadcrumbsTitle(savedSearch, chrome); + addHelpMenuToAppChrome(chrome, docLinks); + }, [savedSearch, chrome, docLinks]); + const { onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useMemo( () => getStateColumnActions({ @@ -292,9 +305,9 @@ export function Discover({ {!hideChart && ( - )} - {isLegacy && } {!hideChart && opts.timefield && ( @@ -341,7 +353,7 @@ export function Discover({ className={isLegacy ? 'dscHistogram' : 'dscHistogramGrid'} data-test-subj="discoverChart" > - @@ -393,6 +405,7 @@ export function Discover({ columns={columns} expandedDoc={expandedDoc} indexPattern={indexPattern} + isLoading={fetchStatus === 'loading'} rows={rows} sort={(state.sort as SortPairArr[]) || []} sampleSize={opts.sampleSize} diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 20d7d80b498a8..1888ae8562a37 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -60,6 +60,10 @@ export interface DiscoverGridProps { * The used index pattern */ indexPattern: IndexPattern; + /** + * Determines if data is currently loaded + */ + isLoading: boolean; /** * Function used to add a column in the document flyout */ @@ -135,6 +139,7 @@ export const DiscoverGrid = ({ ariaLabelledBy, columns, indexPattern, + isLoading, expandedDoc, onAddColumn, onFilter, @@ -258,7 +263,13 @@ export const DiscoverGrid = ({ isDarkMode: services.uiSettings.get('theme:darkMode'), }} > - <> + )} - + ); }; diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx index 1a721a400803e..93b5bf8fde0c1 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx @@ -64,11 +64,14 @@ describe('Discover grid columns ', function () { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": undefined, + "cellActions": Array [ + [Function], + [Function], + ], "display": undefined, "id": "extension", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, Object { "actions": Object { @@ -80,7 +83,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, ] `); @@ -101,12 +104,15 @@ describe('Discover grid columns ', function () { "showMoveLeft": true, "showMoveRight": true, }, - "cellActions": undefined, + "cellActions": Array [ + [Function], + [Function], + ], "display": "Time (timestamp)", "id": "timestamp", "initialWidth": 180, - "isSortable": false, - "schema": "kibana-json", + "isSortable": true, + "schema": "datetime", }, Object { "actions": Object { @@ -117,11 +123,14 @@ describe('Discover grid columns ', function () { "showMoveLeft": true, "showMoveRight": true, }, - "cellActions": undefined, + "cellActions": Array [ + [Function], + [Function], + ], "display": undefined, "id": "extension", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, Object { "actions": Object { @@ -136,7 +145,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": false, - "schema": "kibana-json", + "schema": "string", }, ] `); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx index 87b9c6243abd8..dbc94e5021294 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx @@ -140,7 +140,7 @@ export function DiscoverGridFlyout({ iconType="documents" flush="left" href={getContextUrl( - hit._id, + String(hit._id), indexPattern.id, columns, services.filterManager, diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index f1025a0881d1f..74cf083d82653 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ReactWrapper, shallow } from 'enzyme'; import { getRenderCellValueFn } from './get_render_cell_value'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; jest.mock('../../../../../kibana_react/public', () => ({ useUiSetting: () => true, @@ -26,7 +27,7 @@ jest.mock('../../../kibana_services', () => ({ }), })); -const rowsSource = [ +const rowsSource: ElasticSearchHit[] = [ { _id: '1', _index: 'test', @@ -34,12 +35,12 @@ const rowsSource = [ _score: 1, _source: { bytes: 100, extension: '.gz' }, highlight: { - extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + extension: ['@kibana-highlighted-field.gz@/kibana-highlighted-field'], }, }, ]; -const rowsFields = [ +const rowsFields: ElasticSearchHit[] = [ { _id: '1', _index: 'test', @@ -48,12 +49,12 @@ const rowsFields = [ _source: undefined, fields: { bytes: [100], extension: ['.gz'] }, highlight: { - extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + extension: ['@kibana-highlighted-field.gz@/kibana-highlighted-field'], }, }, ]; -const rowsFieldsWithTopLevelObject = [ +const rowsFieldsWithTopLevelObject: ElasticSearchHit[] = [ { _id: '1', _index: 'test', @@ -62,7 +63,7 @@ const rowsFieldsWithTopLevelObject = [ _source: undefined, fields: { 'object.value': [100], extension: ['.gz'] }, highlight: { - extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + extension: ['@kibana-highlighted-field.gz@/kibana-highlighted-field'], }, }, ]; @@ -167,7 +168,9 @@ describe('Discover grid cell rendering', function () { }, "_type": "test", "highlight": Object { - "extension": "@kibana-highlighted-field.gz@/kibana-highlighted-field", + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], }, } } @@ -264,7 +267,9 @@ describe('Discover grid cell rendering', function () { ], }, "highlight": Object { - "extension": "@kibana-highlighted-field.gz@/kibana-highlighted-field", + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], }, } } diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss index 95a50b54b5364..f5a4180207c33 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss @@ -1,5 +1,5 @@ .kbnDocViewerTable { - @include euiBreakpoint('xs', 's') { + @include euiBreakpoint('xs', 's','m') { table-layout: fixed; } } @@ -52,7 +52,7 @@ white-space: nowrap; } .kbnDocViewer__buttons { - width: 96px; + width: 108px; // Show all icons if one is focused, &:focus-within { @@ -64,7 +64,7 @@ .kbnDocViewer__field { width: $euiSize * 10; - @include euiBreakpoint('xs', 's') { + @include euiBreakpoint('xs', 's', 'm') { width: $euiSize * 6; } } diff --git a/src/plugins/discover/public/application/components/help_menu/help_menu_util.js b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts similarity index 81% rename from src/plugins/discover/public/application/components/help_menu/help_menu_util.js rename to src/plugins/discover/public/application/components/help_menu/help_menu_util.ts index 1a6815b40b581..d0d5cfde1fe06 100644 --- a/src/plugins/discover/public/application/components/help_menu/help_menu_util.js +++ b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts @@ -7,10 +7,9 @@ */ import { i18n } from '@kbn/i18n'; -import { getServices } from '../../../kibana_services'; -const { docLinks } = getServices(); +import { ChromeStart, DocLinksStart } from 'kibana/public'; -export function addHelpMenuToAppChrome(chrome) { +export function addHelpMenuToAppChrome(chrome: ChromeStart, docLinks: DocLinksStart) { chrome.setHelpExtension({ appName: i18n.translate('discover.helpMenu.appName', { defaultMessage: 'Discover', diff --git a/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.test.ts b/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.test.ts new file mode 100644 index 0000000000000..29c93886ebba3 --- /dev/null +++ b/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield'; +import { SearchSource } from '../../../../../data/public'; +import { dataPluginMock } from '../../../../../data/public/mocks'; +import { applyAggsToSearchSource } from './apply_aggs_to_search_source'; + +describe('applyAggsToSearchSource', () => { + test('enabled = true', () => { + const indexPattern = indexPatternWithTimefieldMock; + const setField = jest.fn(); + const searchSource = ({ + setField, + removeField: jest.fn(), + } as unknown) as SearchSource; + + const dataMock = dataPluginMock.createStartContract(); + + const aggsConfig = applyAggsToSearchSource(true, searchSource, 'auto', indexPattern, dataMock); + + expect(aggsConfig!.aggs).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "id": "1", + "params": Object {}, + "schema": "metric", + "type": "count", + }, + Object { + "enabled": true, + "id": "2", + "params": Object { + "drop_partials": false, + "extended_bounds": Object {}, + "field": "timestamp", + "interval": "auto", + "min_doc_count": 1, + "scaleMetricValues": false, + "useNormalizedEsInterval": true, + }, + "schema": "segment", + "type": "date_histogram", + }, + ] + `); + + expect(setField).toHaveBeenCalledWith('aggs', expect.any(Function)); + const dslFn = setField.mock.calls[0][1]; + expect(dslFn()).toMatchInlineSnapshot(` + Object { + "2": Object { + "date_histogram": Object { + "field": "timestamp", + "min_doc_count": 1, + "time_zone": "America/New_York", + }, + }, + } + `); + }); + + test('enabled = false', () => { + const indexPattern = indexPatternWithTimefieldMock; + const setField = jest.fn(); + const getField = jest.fn(() => { + return true; + }); + const removeField = jest.fn(); + const searchSource = ({ + getField, + setField, + removeField, + } as unknown) as SearchSource; + + const dataMock = dataPluginMock.createStartContract(); + + const aggsConfig = applyAggsToSearchSource(false, searchSource, 'auto', indexPattern, dataMock); + expect(aggsConfig).toBeFalsy(); + expect(getField).toHaveBeenCalledWith('aggs'); + expect(removeField).toHaveBeenCalledWith('aggs'); + }); +}); diff --git a/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.ts b/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.ts new file mode 100644 index 0000000000000..c5fb366f81c8c --- /dev/null +++ b/src/plugins/discover/public/application/components/histogram/apply_aggs_to_search_source.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { IndexPattern, SearchSource } from '../../../../../data/common'; +import { DataPublicPluginStart } from '../../../../../data/public'; + +/** + * Helper function to apply or remove aggregations to a given search source used for gaining data + * for Discover's histogram vis + */ +export function applyAggsToSearchSource( + enabled: boolean, + searchSource: SearchSource, + histogramInterval: string, + indexPattern: IndexPattern, + data: DataPublicPluginStart +) { + if (!enabled) { + if (searchSource.getField('aggs')) { + // clean up fields in case it was set before + searchSource.removeField('aggs'); + } + return; + } + const visStateAggs = [ + { + type: 'count', + schema: 'metric', + }, + { + type: 'date_histogram', + schema: 'segment', + params: { + field: indexPattern.timeFieldName!, + interval: histogramInterval, + timeRange: data.query.timefilter.timefilter.getTime(), + }, + }, + ]; + const chartAggConfigs = data.search.aggs.createAggConfigs(indexPattern, visStateAggs); + + searchSource.setField('aggs', function () { + return chartAggConfigs.toDsl(); + }); + return chartAggConfigs; +} diff --git a/src/plugins/discover/public/application/components/histogram/get_dimensions.test.ts b/src/plugins/discover/public/application/components/histogram/get_dimensions.test.ts new file mode 100644 index 0000000000000..ad7031f331992 --- /dev/null +++ b/src/plugins/discover/public/application/components/histogram/get_dimensions.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { dataPluginMock } from '../../../../../data/public/mocks'; + +import { getDimensions } from './get_dimensions'; +import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield'; +import { SearchSource } from '../../../../../data/common/search/search_source'; +import { applyAggsToSearchSource } from './apply_aggs_to_search_source'; +import { calculateBounds } from '../../../../../data/common/query/timefilter'; + +test('getDimensions', () => { + const indexPattern = indexPatternWithTimefieldMock; + const setField = jest.fn(); + const searchSource = ({ + setField, + removeField: jest.fn(), + } as unknown) as SearchSource; + + const dataMock = dataPluginMock.createStartContract(); + dataMock.query.timefilter.timefilter.getTime = () => { + return { from: 'now-30y', to: 'now' }; + }; + dataMock.query.timefilter.timefilter.calculateBounds = (timeRange) => { + return calculateBounds(timeRange); + }; + + const aggsConfig = applyAggsToSearchSource(true, searchSource, 'auto', indexPattern, dataMock); + const actual = getDimensions(aggsConfig!, dataMock); + expect(actual).toMatchInlineSnapshot(` + Object { + "x": Object { + "accessor": 0, + "format": Object { + "id": "date", + "params": Object { + "pattern": "HH:mm:ss.SSS", + }, + }, + "label": "timestamp per 0 milliseconds", + "params": Object { + "bounds": undefined, + "date": true, + "format": "HH:mm:ss.SSS", + "interval": "P365D", + "intervalESUnit": "d", + "intervalESValue": 365, + }, + }, + "y": Object { + "accessor": 1, + "format": Object { + "id": "number", + }, + "label": "Count", + }, + } + `); +}); diff --git a/src/plugins/discover/public/application/components/histogram/get_dimensions.ts b/src/plugins/discover/public/application/components/histogram/get_dimensions.ts new file mode 100644 index 0000000000000..6743c1c8431b9 --- /dev/null +++ b/src/plugins/discover/public/application/components/histogram/get_dimensions.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 moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { IAggConfigs, TimeRangeBounds } from '../../../../../data/common'; +import { DataPublicPluginStart, search } from '../../../../../data/public'; + +export function getDimensions(aggs: IAggConfigs, data: DataPublicPluginStart) { + const [metric, agg] = aggs.aggs; + const { from, to } = data.query.timefilter.timefilter.getTime(); + agg.params.timeRange = { + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), + }; + const bounds = agg.params.timeRange + ? data.query.timefilter.timefilter.calculateBounds(agg.params.timeRange) + : null; + const buckets = search.aggs.isDateHistogramBucketAggConfig(agg) ? agg.buckets : undefined; + + if (!buckets) { + return; + } + + buckets.setBounds(bounds as TimeRangeBounds); + + const { esUnit, esValue } = buckets.getInterval(); + return { + x: { + accessor: 0, + label: agg.makeLabel(), + format: agg.toSerializedFieldFormat(), + params: { + date: true, + interval: moment.duration(esValue, esUnit), + intervalESValue: esValue, + intervalESUnit: esUnit, + format: buckets.getScaledDateFormat(), + bounds: buckets.getBounds(), + }, + }, + y: { + accessor: 1, + format: metric.toSerializedFieldFormat(), + label: metric.makeLabel(), + }, + }; +} diff --git a/src/plugins/discover/public/application/components/histogram/index.ts b/src/plugins/discover/public/application/components/histogram/index.ts new file mode 100644 index 0000000000000..4af75de0a029d --- /dev/null +++ b/src/plugins/discover/public/application/components/histogram/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { applyAggsToSearchSource } from './apply_aggs_to_search_source'; +export { getDimensions } from './get_dimensions'; diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 6de41aa0643a5..8997c1d13a474 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -87,7 +87,7 @@ describe('DocViewTable at Discover', () => { scripted: 123, _underscore: 123, }, - }; + } as any; const props = { hit, @@ -185,7 +185,7 @@ describe('DocViewTable at Discover Context', () => { Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. \ Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut', }, - }; + } as any; const props = { hit, columns: ['extension'], diff --git a/src/plugins/discover/public/application/components/table/table_row.tsx b/src/plugins/discover/public/application/components/table/table_row.tsx index 731dbeed85cc8..5c6ae49770bc7 100644 --- a/src/plugins/discover/public/application/components/table/table_row.tsx +++ b/src/plugins/discover/public/application/components/table/table_row.tsx @@ -54,6 +54,26 @@ export function DocViewTableRow({ const key = field ? field : fieldMapping?.displayName; return ( + {typeof onFilter === 'function' && ( + + onFilter(fieldMapping, valueRaw, '+')} + /> + onFilter(fieldMapping, valueRaw, '-')} + /> + {typeof onToggleColumn === 'function' && ( + + )} + onFilter('_exists_', field, '+')} + scripted={fieldMapping && fieldMapping.scripted} + /> + + )} {field ? ( - {typeof onFilter === 'function' && ( - - onFilter(fieldMapping, valueRaw, '+')} - /> - onFilter(fieldMapping, valueRaw, '-')} - /> - {typeof onToggleColumn === 'function' && ( - - )} - onFilter('_exists_', field, '+')} - scripted={fieldMapping && fieldMapping.scripted} - /> - - )} ); } diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx index ff8f14115e492..74836711373b2 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx @@ -12,6 +12,7 @@ import { ReactWrapper } from 'enzyme'; import { TimechartHeader, TimechartHeaderProps } from './timechart_header'; import { EuiIconTip } from '@elastic/eui'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { DataPublicPluginStart } from '../../../../../data/public'; describe('timechart header', function () { let props: TimechartHeaderProps; @@ -19,10 +20,18 @@ describe('timechart header', function () { beforeAll(() => { props = { - timeRange: { - from: 'May 14, 2020 @ 11:05:13.590', - to: 'May 14, 2020 @ 11:20:13.590', - }, + data: { + query: { + timefilter: { + timefilter: { + getTime: () => { + return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; + }, + }, + }, + }, + } as DataPublicPluginStart, + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', stateInterval: 's', options: [ { diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx index 0379059b80e58..a2fc17e05a203 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx @@ -15,9 +15,11 @@ import { EuiSelect, EuiIconTip, } from '@elastic/eui'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; +import dateMath from '@elastic/datemath'; +import { DataPublicPluginStart } from '../../../../../data/public'; import './timechart_header.scss'; -import moment from 'moment'; export interface TimechartHeaderProps { /** @@ -32,13 +34,7 @@ export interface TimechartHeaderProps { description?: string; scale?: number; }; - /** - * Range of dates to be displayed - */ - timeRange?: { - from: string; - to: string; - }; + data: DataPublicPluginStart; /** * Interval Options */ @@ -56,21 +52,27 @@ export interface TimechartHeaderProps { export function TimechartHeader({ bucketInterval, dateFormat, - timeRange, + data, options, onChangeInterval, stateInterval, }: TimechartHeaderProps) { + const { timefilter } = data.query.timefilter; + const { from, to } = timefilter.getTime(); + const timeRange = { + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), + }; const [interval, setInterval] = useState(stateInterval); const toMoment = useCallback( - (datetime: string) => { + (datetime: moment.Moment | undefined) => { if (!datetime) { return ''; } if (!dateFormat) { - return datetime; + return String(datetime); } - return moment(datetime).format(dateFormat); + return datetime.format(dateFormat); }, [dateFormat] ); diff --git a/src/plugins/discover/public/application/components/types.ts b/src/plugins/discover/public/application/components/types.ts index e488f596cece8..23a3cc9a9bc74 100644 --- a/src/plugins/discover/public/application/components/types.ts +++ b/src/plugins/discover/public/application/components/types.ts @@ -42,6 +42,10 @@ export interface DiscoverProps { * Statistics by fields calculated using the fetched documents */ fieldCounts: Record; + /** + * Current state of data fetching + */ + fetchStatus: string; /** * Histogram aggregation data */ @@ -154,10 +158,6 @@ export interface DiscoverProps { * Current app state of URL */ state: AppState; - /** - * Currently selected time range - */ - timeRange?: { from: string; to: string }; /** * An object containing properties for unmapped fields behavior */ diff --git a/src/plugins/discover/public/application/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts index b06b242ee9ea3..02ac951f7f57c 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -8,7 +8,7 @@ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { IndexPattern } from '../../../../data/public'; export interface AngularDirective { @@ -18,7 +18,7 @@ export interface AngularDirective { export type AngularScope = IScope; -export type ElasticSearchHit = SearchResponse['hits']['hits'][number]; +export type ElasticSearchHit = estypes.SearchResponse['hits']['hits'][number]; export interface FieldMapping { filterable?: boolean; diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index 1bf4cdc947be9..e7349ed22355a 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -168,7 +168,7 @@ export class SearchEmbeddable throw new Error('Search scope not defined'); } this.searchInstance = this.$compile( - this.services.uiSettings.get('doc_table:legacy', true) ? searchTemplate : searchTemplateGrid + this.services.uiSettings.get('doc_table:legacy') ? searchTemplate : searchTemplateGrid )(this.searchScope); const rootNode = angular.element(domNode); rootNode.append(this.searchInstance); @@ -226,6 +226,8 @@ export class SearchEmbeddable this.updateInput({ sort }); }; + searchScope.isLoading = true; + const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false); searchScope.useNewFieldsApi = useNewFieldsApi; @@ -336,6 +338,9 @@ export class SearchEmbeddable searchSource.getSearchRequestBody().then((body: Record) => { inspectorRequest.json(body); }); + this.searchScope.$apply(() => { + this.searchScope!.isLoading = true; + }); this.updateOutput({ loading: true, error: undefined }); try { @@ -352,10 +357,14 @@ export class SearchEmbeddable // Apply the changes to the angular scope this.searchScope.$apply(() => { this.searchScope!.hits = resp.hits.hits; - this.searchScope!.totalHitCount = resp.hits.total; + this.searchScope!.totalHitCount = resp.hits.total as number; + this.searchScope!.isLoading = false; }); } catch (error) { this.updateOutput({ loading: false, error }); + this.searchScope.$apply(() => { + this.searchScope!.isLoading = false; + }); } }; diff --git a/src/plugins/discover/public/application/embeddable/search_template_datagrid.html b/src/plugins/discover/public/application/embeddable/search_template_datagrid.html index 6524783897f8f..8ad7938350d9c 100644 --- a/src/plugins/discover/public/application/embeddable/search_template_datagrid.html +++ b/src/plugins/discover/public/application/embeddable/search_template_datagrid.html @@ -1,16 +1,16 @@ { + test('fetching uninitialized', () => { + const actual = getResultState(fetchStatuses.UNINITIALIZED, []); + expect(actual).toBe(resultStatuses.UNINITIALIZED); + }); + + test('fetching complete with no records', () => { + const actual = getResultState(fetchStatuses.COMPLETE, []); + expect(actual).toBe(resultStatuses.NO_RESULTS); + }); + + test('fetching ongoing aka loading', () => { + const actual = getResultState(fetchStatuses.LOADING, []); + expect(actual).toBe(resultStatuses.LOADING); + }); + + test('fetching ready', () => { + const record = ({ _id: 123 } as unknown) as ElasticSearchHit; + const actual = getResultState(fetchStatuses.COMPLETE, [record]); + expect(actual).toBe(resultStatuses.READY); + }); + + test('re-fetching after already data is available', () => { + const record = ({ _id: 123 } as unknown) as ElasticSearchHit; + const actual = getResultState(fetchStatuses.LOADING, [record]); + expect(actual).toBe(resultStatuses.READY); + }); + + test('after a fetch error when data was successfully fetched before ', () => { + const record = ({ _id: 123 } as unknown) as ElasticSearchHit; + const actual = getResultState(fetchStatuses.ERROR, [record]); + expect(actual).toBe(resultStatuses.READY); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_result_state.ts b/src/plugins/discover/public/application/helpers/get_result_state.ts new file mode 100644 index 0000000000000..6f69832f369fd --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_result_state.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ElasticSearchHit } from '../doc_views/doc_views_types'; +import { fetchStatuses } from '../components/constants'; + +export const resultStatuses = { + UNINITIALIZED: 'uninitialized', + LOADING: 'loading', // initial data load + READY: 'ready', // results came back + NO_RESULTS: 'none', // no results came back +}; + +/** + * Returns the current state of the result, depends on fetchStatus and the given fetched rows + * Determines what is displayed in Discover main view (loading view, data view, empty data view, ...) + */ +export function getResultState(fetchStatus: string, rows: ElasticSearchHit[]) { + if (fetchStatus === fetchStatuses.UNINITIALIZED) { + return resultStatuses.UNINITIALIZED; + } + + const rowsEmpty = !Array.isArray(rows) || rows.length === 0; + if (rowsEmpty && fetchStatus === fetchStatuses.LOADING) return resultStatuses.LOADING; + else if (!rowsEmpty) return resultStatuses.READY; + else return resultStatuses.NO_RESULTS; +} diff --git a/src/plugins/discover/public/application/helpers/get_state_defaults.test.ts b/src/plugins/discover/public/application/helpers/get_state_defaults.test.ts new file mode 100644 index 0000000000000..7ce5b9286c775 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_state_defaults.test.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 { getStateDefaults } from './get_state_defaults'; +import { createSearchSourceMock, dataPluginMock } from '../../../../data/public/mocks'; +import { uiSettingsMock } from '../../__mocks__/ui_settings'; +import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_with_timefield'; +import { savedSearchMock } from '../../__mocks__/saved_search'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('getStateDefaults', () => { + test('index pattern with timefield', () => { + const actual = getStateDefaults({ + config: uiSettingsMock, + data: dataPluginMock.createStartContract(), + indexPattern: indexPatternWithTimefieldMock, + savedSearch: savedSearchMock, + searchSource: createSearchSourceMock({ index: indexPatternWithTimefieldMock }), + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "default_column", + ], + "filters": undefined, + "index": "index-pattern-with-timefield-id", + "interval": "auto", + "query": undefined, + "sort": Array [ + Array [ + "timestamp", + "desc", + ], + ], + } + `); + }); + + test('index pattern without timefield', () => { + const actual = getStateDefaults({ + config: uiSettingsMock, + data: dataPluginMock.createStartContract(), + indexPattern: indexPatternMock, + savedSearch: savedSearchMock, + searchSource: createSearchSourceMock({ index: indexPatternMock }), + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "default_column", + ], + "filters": undefined, + "index": "the-index-pattern-id", + "interval": "auto", + "query": undefined, + "sort": Array [], + } + `); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_state_defaults.ts b/src/plugins/discover/public/application/helpers/get_state_defaults.ts new file mode 100644 index 0000000000000..3e012a1f85fd6 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_state_defaults.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloneDeep } from 'lodash'; +import { IUiSettingsClient } from 'kibana/public'; +import { DEFAULT_COLUMNS_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { getSortArray } from '../angular/doc_table'; +import { getDefaultSort } from '../angular/doc_table/lib/get_default_sort'; +import { SavedSearch } from '../../saved_searches'; +import { SearchSource } from '../../../../data/common/search/search_source'; +import { DataPublicPluginStart, IndexPattern } from '../../../../data/public'; + +import { AppState } from '../angular/discover_state'; + +function getDefaultColumns(savedSearch: SavedSearch, config: IUiSettingsClient) { + if (savedSearch.columns && savedSearch.columns.length > 0) { + return [...savedSearch.columns]; + } + return [...config.get(DEFAULT_COLUMNS_SETTING)]; +} + +export function getStateDefaults({ + config, + data, + indexPattern, + savedSearch, + searchSource, +}: { + config: IUiSettingsClient; + data: DataPublicPluginStart; + indexPattern: IndexPattern; + savedSearch: SavedSearch; + searchSource: SearchSource; +}) { + const query = searchSource.getField('query') || data.query.queryString.getDefaultQuery(); + const sort = getSortArray(savedSearch.sort, indexPattern); + const columns = getDefaultColumns(savedSearch, config); + + const defaultState = { + query, + sort: !sort.length + ? getDefaultSort(indexPattern, config.get(SORT_DEFAULT_ORDER_SETTING, 'desc')) + : sort, + columns, + index: indexPattern.id, + interval: 'auto', + filters: cloneDeep(searchSource.getOwnField('filter')), + } as AppState; + if (savedSearch.grid) { + defaultState.grid = savedSearch.grid; + } + if (savedSearch.hideChart) { + defaultState.hideChart = savedSearch.hideChart; + } + + return defaultState; +} diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 189f71b85206b..b9719542adc81 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -15,6 +15,7 @@ import * as CSS from 'csstype'; import { DetailedPeerCertificate } from 'tls'; import { EmbeddableStart as EmbeddableStart_2 } from 'src/plugins/embeddable/public/plugin'; import { EnvironmentMode } from '@kbn/config'; +import { estypes } from '@elastic/elasticsearch'; import { EuiBreadcrumb } from '@elastic/eui'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts index 18eccb4e87090..c57333d788ef5 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies export { registerTestBed, TestBed } from '@kbn/test/jest'; +// eslint-disable-next-line import/no-extraneous-dependencies export { getRandomString } from '@kbn/test/jest'; diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index a897ef5222bfa..d9c8682567b30 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -11,7 +11,6 @@ import type { KibanaRequest } from 'src/core/server'; import { ExpressionType, SerializableState } from '../expression_types'; import { Adapters, RequestAdapter } from '../../../inspector/common'; -import { SavedObject, SavedObjectAttributes } from '../../../../core/public'; import { TablesAdapter } from '../util/tables_adapter'; /** @@ -59,20 +58,6 @@ export interface ExecutionContext< */ getKibanaRequest?: () => KibanaRequest; - /** - * Allows to fetch saved objects from ElasticSearch. In browser `getSavedObject` - * function is provided automatically by the Expressions plugin. On the server - * the caller of the expression has to provide this context function. The - * reason is because on the browser we always know the user who tries to - * fetch a saved object, thus saved object client is scoped automatically to - * that user. However, on the server we can scope that saved object client to - * any user, or even not scope it at all and execute it as an "internal" user. - */ - getSavedObject?: ( - type: string, - id: string - ) => Promise>; - /** * Returns the state (true|false) of the sync colors across panels switch. */ diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 7962fe723d19f..255de31f7239b 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -215,17 +215,25 @@ export class Executor = Record { - link.arguments = fn.inject(link.arguments, references); + link.arguments = fn.inject( + link.arguments, + references + .filter((r) => r.name.includes(`l${linkId}_`)) + .map((r) => ({ ...r, name: r.name.replace(`l${linkId}_`, '') })) + ); + linkId++; }); } public extract(ast: ExpressionAstExpression) { + let linkId = 0; const allReferences: SavedObjectReference[] = []; const newAst = this.walkAst(cloneDeep(ast), (fn, link) => { const { state, references } = fn.extract(link.arguments); link.arguments = state; - allReferences.push(...references); + allReferences.push(...references.map((r) => ({ ...r, name: `l${linkId++}_${r.name}` }))); }); return { state: newAst, references: allReferences }; } diff --git a/src/plugins/expressions/public/plugin.ts b/src/plugins/expressions/public/plugin.ts index 2bff5e09352e4..2410ad8741312 100644 --- a/src/plugins/expressions/public/plugin.ts +++ b/src/plugins/expressions/public/plugin.ts @@ -7,12 +7,7 @@ */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { - ExpressionsService, - ExpressionsServiceSetup, - ExecutionContext, - ExpressionsServiceStart, -} from '../common'; +import { ExpressionsService, ExpressionsServiceSetup, ExpressionsServiceStart } from '../common'; import { setRenderersRegistry, setNotifications, setExpressionsService } from './services'; import { ReactExpressionRenderer } from './react_expression_renderer'; import { ExpressionLoader, IExpressionLoader, loader } from './loader'; @@ -42,14 +37,8 @@ export class ExpressionsPublicPlugin implements Plugin { - const [start] = await core.getStartServices(); - return start.savedObjects.client.get(type, id); - }; - executor.extendContext({ environment: 'client', - getSavedObject, }); } diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index 029d727e82e74..b3e7803f97c38 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -137,9 +137,6 @@ export type ExecutionContainer = StateContainer { abortSignal: AbortSignal; getKibanaRequest?: () => KibanaRequest; - // Warning: (ae-forgotten-export) The symbol "SavedObjectAttributes" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "SavedObject" needs to be exported by the entry point index.d.ts - getSavedObject?: (type: string, id: string) => Promise>; getSearchContext: () => ExecutionContextSearch; getSearchSessionId: () => string | undefined; inspectorAdapters: InspectorAdapters; diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index de9797843a4ab..2d873fa518306 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -135,9 +135,6 @@ export type ExecutionContainer = StateContainer { abortSignal: AbortSignal; getKibanaRequest?: () => KibanaRequest; - // Warning: (ae-forgotten-export) The symbol "SavedObjectAttributes" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "SavedObject" needs to be exported by the entry point index.d.ts - getSavedObject?: (type: string, id: string) => Promise>; getSearchContext: () => ExecutionContextSearch; getSearchSessionId: () => string | undefined; inspectorAdapters: InspectorAdapters; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index 9b69dacd8fdb5..cfac42b97c686 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -109,7 +109,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Promotion Tracking', }), visState: - '{"title":"[eCommerce] Promotion Tracking","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(240,138,217,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*trouser*","label":"Revenue Trousers","value_template":"${{value}}"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(191,240,129,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*watch*","label":"Revenue Watches","value_template":"${{value}}"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(23,233,230,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*bag*","label":"Revenue Bags","value_template":"${{value}}"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(235,186,180,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*cocktail dress*","label":"Revenue Cocktail Dresses","value_template":"${{value}}"}],"time_field":"order_date","index_pattern":"kibana_sample_data_ecommerce","interval":">=12h","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","index_pattern":"kibana_sample_data_ecommerce","query_string":"taxful_total_price:>250","id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1}]},"aggs":[]}', + '{"title":"[eCommerce] Promotion Tracking","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(240,138,217,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*trouser*","label":"Revenue Trousers","value_template":"${{value}}"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(191,240,129,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*watch*","label":"Revenue Watches","value_template":"${{value}}"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(23,233,230,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*bag*","label":"Revenue Bags","value_template":"${{value}}"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(235,186,180,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*cocktail dress*","label":"Revenue Cocktail Dresses","value_template":"${{value}}"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":">=12h","use_kibana_indexes":true,"axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","index_pattern_ref_name":"ref_2_index_pattern","query_string":"taxful_total_price:>250","id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1}]},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -117,7 +117,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + ], }, { id: '10f1a240-b891-11e8-a6d9-e546fe2bba5f', @@ -152,7 +163,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Sold Products per Day', }), visState: - '{"title":"[eCommerce] Sold Products per Day","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day"}],"time_field":"order_date","index_pattern":"kibana_sample_data_ecommerce","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":10,"gauge_style":"half","filter":"","gauge_max":"300"},"aggs":[]}', + '{"title":"[eCommerce] Sold Products per Day","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":10,"gauge_style":"half","filter":"","gauge_max":"300","use_kibana_indexes":true},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -160,7 +171,13 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + ], }, { id: '4b3ec120-b892-11e8-a6d9-e546fe2bba5f', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index b316835029d7c..f16c1c7104417 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -144,7 +144,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Delays & Cancellations', }), visState: - '{"title":"[Flights] Delays & Cancellations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(0,156,224,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"filter_ratio","numerator":"FlightDelay:true"}],"separate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":0.5,"stacked":"none","label":"Percent Delays"}],"time_field":"timestamp","index_pattern":"kibana_sample_data_flights","interval":">=1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"FlightDelay,Cancelled,Carrier","template":"{{Carrier}}: Flight Delayed and Cancelled!","index_pattern":"kibana_sample_data_flights","query_string":"FlightDelay:true AND Cancelled:true","id":"53b7dff0-4c89-11e8-a66a-6989ad5a0a39","color":"rgba(0,98,177,1)","time_field":"timestamp","icon":"fa-exclamation-triangle","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom"},"aggs":[]}', + '{"title":"[Flights] Delays & Cancellations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(0,156,224,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"filter_ratio","numerator":"FlightDelay:true"}],"separate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":0.5,"stacked":"none","label":"Percent Delays"}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","interval":">=1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"FlightDelay,Cancelled,Carrier","template":"{{Carrier}}: Flight Delayed and Cancelled!","index_pattern_ref_name":"ref_2_index_pattern","query_string":"FlightDelay:true AND Cancelled:true","id":"53b7dff0-4c89-11e8-a66a-6989ad5a0a39","color":"rgba(0,98,177,1)","time_field":"timestamp","icon":"fa-exclamation-triangle","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","use_kibana_indexes":true},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -152,7 +152,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d' + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d' + } + ] }, { id: '9886b410-4c8b-11e8-b3d7-01146121b73d', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 0396cb58d3692..8a3469fe4f3c0 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -89,7 +89,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Host, Visits and Bytes Table', }), visState: - '{"title":"[Logs] Host, Visits and Bytes Table","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"table","series":[{"id":"bd09d600-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum","field":"bytes"},{"sigma":"","id":"c9514c90-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum_bucket","field":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Total)"},{"id":"b7672c30-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"b7672c31-a6df-11e8-8b18-1da1dfc50975","type":"sum","field":"bytes"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Last Hour)"},{"id":"f2c20700-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"f2c20701-a6df-11e8-8b18-1da1dfc50975","type":"cardinality","field":"ip"},{"sigma":"","id":"f46333e0-a6df-11e8-8b18-1da1dfc50975","type":"sum_bucket","field":"f2c20701-a6df-11e8-8b18-1da1dfc50975"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Total)","color_rules":[{"value":1000,"id":"2e963080-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":1000,"id":"3d4fb880-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":1500,"id":"435f8a20-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1},{"id":"46fd7fc0-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"46fd7fc1-e5b1-11e7-bfc2-a1f7e71965a1","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Last Hour)","color_rules":[{"value":10,"id":"4e90aeb0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":10,"id":"6d59b1c0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":25,"id":"77578670-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1}],"time_field":"timestamp","index_pattern":"kibana_sample_data_logs","interval":"1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"bar_color_rules":[{"id":"e9b4e490-e1c6-11e7-b4f6-0f68c45f7387"}],"pivot_id":"extension.keyword","pivot_label":"Type","drilldown_url":"","axis_scale":"normal"},"aggs":[]}', + '{"title":"[Logs] Host, Visits and Bytes Table","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"table","series":[{"id":"bd09d600-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum","field":"bytes"},{"sigma":"","id":"c9514c90-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum_bucket","field":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Total)"},{"id":"b7672c30-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"b7672c31-a6df-11e8-8b18-1da1dfc50975","type":"sum","field":"bytes"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Last Hour)"},{"id":"f2c20700-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"f2c20701-a6df-11e8-8b18-1da1dfc50975","type":"cardinality","field":"ip"},{"sigma":"","id":"f46333e0-a6df-11e8-8b18-1da1dfc50975","type":"sum_bucket","field":"f2c20701-a6df-11e8-8b18-1da1dfc50975"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Total)","color_rules":[{"value":1000,"id":"2e963080-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":1000,"id":"3d4fb880-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":1500,"id":"435f8a20-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1},{"id":"46fd7fc0-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"46fd7fc1-e5b1-11e7-bfc2-a1f7e71965a1","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Last Hour)","color_rules":[{"value":10,"id":"4e90aeb0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":10,"id":"6d59b1c0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":25,"id":"77578670-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","use_kibana_indexes": true,"interval":"1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"bar_color_rules":[{"id":"e9b4e490-e1c6-11e7-b4f6-0f68c45f7387"}],"pivot_id":"extension.keyword","pivot_label":"Type","drilldown_url":"","axis_scale":"normal"},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -97,7 +97,13 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + ], }, { id: '69a34b00-9ee8-11e7-8711-e7a007dcef99', @@ -175,7 +181,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Response Codes Over Time + Annotations', }), visState: - '{"title":"[Logs] Response Codes Over Time + Annotations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(115,216,255,1)","split_mode":"terms","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":"0.5","stacked":"percent","terms_field":"response.keyword","terms_order_by":"61ca57f2-469d-11e7-af02-69e470af7417","label":"Response Code Count","split_color_mode":"gradient"}],"time_field":"timestamp","index_pattern":"kibana_sample_data_logs","interval":">=4h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"geo.src, host","template":"Security Error from {{geo.src}} on {{host}}","index_pattern":"kibana_sample_data_logs","query_string":"tags:error AND tags:security","id":"bd7548a0-2223-11e8-832f-d5027f3c8a47","color":"rgba(211,49,21,1)","time_field":"timestamp","icon":"fa-asterisk","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","axis_scale":"normal","drop_last_bucket":0},"aggs":[]}', + '{"title":"[Logs] Response Codes Over Time + Annotations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(115,216,255,1)","split_mode":"terms","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":"0.5","stacked":"percent","terms_field":"response.keyword","terms_order_by":"61ca57f2-469d-11e7-af02-69e470af7417","label":"Response Code Count","split_color_mode":"gradient"}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","use_kibana_indexes":true,"interval":">=4h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"geo.src, host","template":"Security Error from {{geo.src}} on {{host}}","index_pattern_ref_name":"ref_2_index_pattern","query_string":"tags:error AND tags:security","id":"bd7548a0-2223-11e8-832f-d5027f3c8a47","color":"rgba(211,49,21,1)","time_field":"timestamp","icon":"fa-asterisk","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","axis_scale":"normal","drop_last_bucket":0},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -183,7 +189,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + ], }, { id: '24a3e970-4257-11e8-b3aa-73fdaf54bfc9', diff --git a/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx index fca3eaf10b1ef..73a4837d6e0cc 100644 --- a/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx @@ -6,12 +6,13 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiFieldText, EuiFormRow, EuiSpacer } from '@elastic/eui'; const geti18nTexts = (fieldsToDelete?: string[]) => { let modalTitle = ''; + let confirmButtonText = ''; if (fieldsToDelete) { const isSingle = fieldsToDelete.length === 1; @@ -19,27 +20,35 @@ const geti18nTexts = (fieldsToDelete?: string[]) => { ? i18n.translate( 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteSingleTitle', { - defaultMessage: `Remove field '{name}'?`, + defaultMessage: `Remove field '{name}'`, values: { name: fieldsToDelete[0] }, } ) : i18n.translate( 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteMultipleTitle', { - defaultMessage: `Remove {count} fields?`, + defaultMessage: `Remove {count} fields`, values: { count: fieldsToDelete.length }, } ); + confirmButtonText = isSingle + ? i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel', + { + defaultMessage: `Remove field`, + } + ) + : i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeMultipleButtonLabel', + { + defaultMessage: `Remove fields`, + } + ); } return { modalTitle, - confirmButtonText: i18n.translate( - 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel', - { - defaultMessage: 'Remove', - } - ), + confirmButtonText, cancelButtonText: i18n.translate( 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.cancelButtonLabel', { @@ -52,6 +61,19 @@ const geti18nTexts = (fieldsToDelete?: string[]) => { defaultMessage: 'You are about to remove these runtime fields:', } ), + typeConfirm: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.typeConfirm', + { + defaultMessage: "Type 'REMOVE' to confirm", + } + ), + warningRemovingFields: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningRemovingFields', + { + defaultMessage: + 'Warning: Removing fields may break searches or visualizations that rely on this field.', + } + ), }; }; @@ -65,6 +87,7 @@ export function DeleteFieldModal({ fieldsToDelete, closeModal, confirmDelete }: const i18nTexts = geti18nTexts(fieldsToDelete); const { modalTitle, confirmButtonText, cancelButtonText, warningMultipleFields } = i18nTexts; const isMultiple = Boolean(fieldsToDelete.length > 1); + const [confirmContent, setConfirmContent] = useState(); return ( - {isMultiple && ( - <> -

{warningMultipleFields}

-
    - {fieldsToDelete.map((fieldName) => ( -
  • {fieldName}
  • - ))} -
- - )} + + {isMultiple && ( + <> +

{warningMultipleFields}

+
    + {fieldsToDelete.map((fieldName) => ( +
  • {fieldName}
  • + ))} +
+ + )} +
+ + + setConfirmContent(e.target.value)} + data-test-subj="deleteModalConfirmText" + /> +
); } diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts index e943dbdda998d..46414c264c6b7 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts @@ -68,7 +68,7 @@ describe('', () => { const { find } = setup({ ...defaultProps, field }); - expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`); + expect(find('flyoutTitle').text()).toBe(`Edit field 'foo'`); expect(find('nameField.input').props().value).toBe(field.name); expect(find('typeField').props().value).toBe(field.type); expect(find('scriptField').props().value).toBe(field.script.source); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 1511836da85e7..486df1a7707af 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -8,6 +8,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlyoutHeader, EuiFlyoutBody, @@ -19,6 +20,10 @@ import { EuiButton, EuiCallOut, EuiSpacer, + EuiText, + EuiConfirmModal, + EuiFieldText, + EuiFormRow, } from '@elastic/eui'; import { DocLinksStart, CoreStart } from 'src/core/public'; @@ -30,16 +35,6 @@ import type { Props as FieldEditorProps, FieldEditorFormState } from './field_ed const geti18nTexts = (field?: Field) => { return { - flyoutTitle: field - ? i18n.translate('indexPatternFieldEditor.editor.flyoutEditFieldTitle', { - defaultMessage: 'Edit {fieldName} field', - values: { - fieldName: field.name, - }, - }) - : i18n.translate('indexPatternFieldEditor.editor.flyoutDefaultTitle', { - defaultMessage: 'Create field', - }), closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', { defaultMessage: 'Close', }), @@ -49,6 +44,31 @@ const geti18nTexts = (field?: Field) => { formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { defaultMessage: 'Fix errors in form before continuing.', }), + cancelButtonText: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ), + confirmButtonText: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel', + { + defaultMessage: 'Save', + } + ), + warningChangingFields: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields', + { + defaultMessage: + 'Warning: Changing name or type may break searches or visualizations that rely on this field.', + } + ), + typeConfirm: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', + { + defaultMessage: "Type 'CHANGE' to continue:", + } + ), }; }; @@ -97,6 +117,7 @@ const FieldEditorFlyoutContentComponent = ({ runtimeFieldValidator, isSavingField, }: Props) => { + const isEditingExistingField = !!field; const i18nTexts = geti18nTexts(field); const [formState, setFormState] = useState({ @@ -112,6 +133,8 @@ const FieldEditorFlyoutContentComponent = ({ ); const [isValidating, setIsValidating] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [confirmContent, setConfirmContent] = useState(); const { submit, isValid: isFormValid, isSubmitted } = formState; const { fields } = indexPattern; @@ -129,6 +152,8 @@ const FieldEditorFlyoutContentComponent = ({ const onClickSave = useCallback(async () => { const { isValid, data } = await submit(); + const nameChange = field?.name !== data.name; + const typeChange = field?.type !== data.type; if (isValid) { if (data.script) { @@ -147,9 +172,13 @@ const FieldEditorFlyoutContentComponent = ({ } } - onSave(data); + if (isEditingExistingField && (nameChange || typeChange)) { + setIsModalVisible(true); + } else { + onSave(data); + } } - }, [onSave, submit, runtimeFieldValidator]); + }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]); @@ -180,12 +209,70 @@ const FieldEditorFlyoutContentComponent = ({ [fieldTypeToProcess, namesNotAllowed, existingConcreteFields] ); + const modal = isModalVisible ? ( + { + setIsModalVisible(false); + setConfirmContent(''); + }} + onConfirm={async () => { + const { data } = await submit(); + onSave(data); + }} + > + + + + setConfirmContent(e.target.value)} + data-test-subj="saveModalConfirmText" + /> + + + ) : null; return ( <> - -

{i18nTexts.flyoutTitle}

+ +

+ {field ? ( + + ) : ( + + )} +

+ +

+ {indexPattern.title}, + }} + /> +

+
@@ -246,6 +333,7 @@ const FieldEditorFlyoutContentComponent = ({ )} + {modal} ); }; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx index 9ff26decc1c6e..633906feb785b 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx @@ -57,10 +57,11 @@ export class CreateIndexPatternWizard extends Component< context.services.setBreadcrumbs(getCreateBreadcrumbs()); const type = new URLSearchParams(props.location.search).get('type') || undefined; + const indexPattern = new URLSearchParams(props.location.search).get('name') || ''; this.state = { step: 1, - indexPattern: '', + indexPattern, allIndices: [], remoteClustersExist: false, isInitiallyLoadingIndices: true, diff --git a/src/plugins/index_pattern_management/server/routes/preview_scripted_field.test.ts b/src/plugins/index_pattern_management/server/routes/preview_scripted_field.test.ts index 385b4f04c40f1..1343b20365a44 100644 --- a/src/plugins/index_pattern_management/server/routes/preview_scripted_field.test.ts +++ b/src/plugins/index_pattern_management/server/routes/preview_scripted_field.test.ts @@ -46,8 +46,8 @@ describe('preview_scripted_field route', () => { expect(mockClient.search.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "_source": undefined, "body": Object { + "_source": undefined, "query": Object { "match_all": Object {}, }, @@ -59,10 +59,10 @@ describe('preview_scripted_field route', () => { }, }, }, + "size": 10, + "timeout": "30s", }, "index": "kibana_sample_data_logs", - "size": 10, - "timeout": "30s", } `); @@ -102,12 +102,12 @@ describe('preview_scripted_field route', () => { expect(mockClient.search.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "_source": Array [ - "a", - "b", - "c", - ], "body": Object { + "_source": Array [ + "a", + "b", + "c", + ], "query": Object { "bool": Object { "some": "query", @@ -121,10 +121,10 @@ describe('preview_scripted_field route', () => { }, }, }, + "size": 10, + "timeout": "30s", }, "index": "kibana_sample_data_logs", - "size": 10, - "timeout": "30s", } `); }); diff --git a/src/plugins/index_pattern_management/server/routes/preview_scripted_field.ts b/src/plugins/index_pattern_management/server/routes/preview_scripted_field.ts index 276f6dc0db8bf..cc161859f4189 100644 --- a/src/plugins/index_pattern_management/server/routes/preview_scripted_field.ts +++ b/src/plugins/index_pattern_management/server/routes/preview_scripted_field.ts @@ -30,10 +30,10 @@ export function registerPreviewScriptedFieldRoute(router: IRouter): void { try { const response = await client.search({ index, - _source: additionalFields && additionalFields.length > 0 ? additionalFields : undefined, - size: 10, - timeout: '30s', body: { + _source: additionalFields && additionalFields.length > 0 ? additionalFields : undefined, + size: 10, + timeout: '30s', query: query ?? { match_all: {} }, script_fields: { [name]: { diff --git a/src/plugins/index_pattern_management/server/routes/resolve_index.ts b/src/plugins/index_pattern_management/server/routes/resolve_index.ts index 851a2578231aa..22c214f2adee2 100644 --- a/src/plugins/index_pattern_management/server/routes/resolve_index.ts +++ b/src/plugins/index_pattern_management/server/routes/resolve_index.ts @@ -31,19 +31,11 @@ export function registerResolveIndexRoute(router: IRouter): void { }, }, async (context, req, res) => { - const queryString = req.query.expand_wildcards - ? { expand_wildcards: req.query.expand_wildcards } - : null; - const result = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'transport.request', - { - method: 'GET', - path: `/_resolve/index/${encodeURIComponent(req.params.query)}${ - queryString ? '?' + new URLSearchParams(queryString).toString() : '' - }`, - } - ); - return res.ok({ body: result }); + const { body } = await context.core.elasticsearch.client.asCurrentUser.indices.resolveIndex({ + name: req.params.query, + expand_wildcards: req.query.expand_wildcards || 'open', + }); + return res.ok({ body }); } ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts index 1910ba054bf8e..f072f044925bf 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/constants.ts @@ -12,15 +12,9 @@ export const ROLL_TOTAL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; /** - * Roll daily indices every 30 minutes. - * This means that, assuming a user can visit all the 44 apps we can possibly report - * in the 3 minutes interval the browser reports to the server, up to 22 users can have the same - * behaviour and we wouldn't need to paginate in the transactional documents (less than 10k docs). - * - * Based on a more normal expected use case, the users could visit up to 5 apps in those 3 minutes, - * allowing up to 200 users before reaching the limit. + * Roll daily indices every 24h */ -export const ROLL_DAILY_INDICES_INTERVAL = 30 * 60 * 1000; +export const ROLL_DAILY_INDICES_INTERVAL = 24 * 60 * 60 * 1000; /** * Start rolling indices after 5 minutes up diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts index 676f5fddc16e1..2d2d07d9d1894 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.ts @@ -7,3 +7,4 @@ */ export { registerApplicationUsageCollector } from './telemetry_application_usage_collector'; +export { rollDailyData as migrateTransactionalDocs } from './rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts similarity index 51% rename from src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts index 7d86bc41e0b90..5acd1fb9c9c3a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.test.ts @@ -6,21 +6,16 @@ * Side Public License, v 1. */ -import { rollDailyData, rollTotals } from './rollups'; -import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; -import { - SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../../core/server'; +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TRANSACTIONAL_TYPE } from '../saved_objects_types'; +import { rollDailyData } from './daily'; describe('rollDailyData', () => { const logger = loggingSystemMock.createLogger(); - test('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollDailyData(logger, undefined)).resolves.toBe(undefined); + test('returns false if no savedObjectsClient initialised yet', async () => { + await expect(rollDailyData(logger, undefined)).resolves.toBe(false); }); test('handle empty results', async () => { @@ -33,7 +28,7 @@ describe('rollDailyData', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(true); expect(savedObjectClient.get).not.toBeCalled(); expect(savedObjectClient.bulkCreate).not.toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); @@ -101,7 +96,7 @@ describe('rollDailyData', () => { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(true); expect(savedObjectClient.get).toHaveBeenCalledTimes(2); expect(savedObjectClient.get).toHaveBeenNthCalledWith( 1, @@ -196,7 +191,7 @@ describe('rollDailyData', () => { throw new Error('Something went terribly wrong'); }); - await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(false); expect(savedObjectClient.get).toHaveBeenCalledTimes(1); expect(savedObjectClient.get).toHaveBeenCalledWith( SAVED_OBJECTS_DAILY_TYPE, @@ -206,185 +201,3 @@ describe('rollDailyData', () => { expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); }); }); - -describe('rollTotals', () => { - const logger = loggingSystemMock.createLogger(); - - test('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); - }); - - test('handle empty results', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { - switch (type) { - case SAVED_OBJECTS_DAILY_TYPE: - case SAVED_OBJECTS_TOTAL_TYPE: - return { saved_objects: [], total: 0, page, per_page: perPage }; - default: - throw new Error(`Unexpected type [${type}]`); - } - }); - await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); - }); - - test('migrate some documents', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { - switch (type) { - case SAVED_OBJECTS_DAILY_TYPE: - return { - saved_objects: [ - { - id: 'appId-2:2020-01-01', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-2', - timestamp: '2020-01-01T10:31:00.000Z', - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'appId-1:2020-01-01', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - timestamp: '2020-01-01T11:31:00.000Z', - minutesOnScreen: 2.5, - numberOfClicks: 2, - }, - }, - { - id: 'appId-1:2020-01-01:viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - timestamp: '2020-01-01T11:31:00.000Z', - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 3, - page, - per_page: perPage, - }; - case SAVED_OBJECTS_TOTAL_TYPE: - return { - saved_objects: [ - { - id: 'appId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'appId-1___viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - minutesOnScreen: 4, - numberOfClicks: 2, - }, - }, - { - id: 'appId-2___viewId-1', - type, - score: 0, - references: [], - attributes: { - appId: 'appId-2', - viewId: 'viewId-1', - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 3, - page, - per_page: perPage, - }; - default: - throw new Error(`Unexpected type [${type}]`); - } - }); - await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-1', - attributes: { - appId: 'appId-1', - viewId: MAIN_APP_DEFAULT_VIEW_ID, - minutesOnScreen: 3.0, - numberOfClicks: 3, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-1___viewId-1', - attributes: { - appId: 'appId-1', - viewId: 'viewId-1', - minutesOnScreen: 5.0, - numberOfClicks: 3, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-2___viewId-1', - attributes: { - appId: 'appId-2', - viewId: 'viewId-1', - minutesOnScreen: 1.0, - numberOfClicks: 1, - }, - }, - { - type: SAVED_OBJECTS_TOTAL_TYPE, - id: 'appId-2', - attributes: { - appId: 'appId-2', - viewId: MAIN_APP_DEFAULT_VIEW_ID, - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(3); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-2:2020-01-01' - ); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-1:2020-01-01' - ); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId-1:2020-01-01:viewId-1' - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts similarity index 55% rename from src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts index df7e7662b49cf..a7873c7d5dfe9 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/daily.ts @@ -6,18 +6,20 @@ * Side Public License, v 1. */ -import { ISavedObjectsRepository, SavedObject, Logger } from 'kibana/server'; import moment from 'moment'; +import type { Logger } from '@kbn/logging'; +import { + ISavedObjectsRepository, + SavedObject, + SavedObjectsErrorHelpers, +} from '../../../../../../core/server'; +import { getDailyId } from '../../../../../usage_collection/common/application_usage'; import { ApplicationUsageDaily, - ApplicationUsageTotal, ApplicationUsageTransactional, SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; +} from '../saved_objects_types'; /** * For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests) @@ -27,18 +29,17 @@ type ApplicationUsageDailyWithVersion = Pick< 'version' | 'attributes' >; -export function serializeKey(appId: string, viewId: string) { - return `${appId}___${viewId}`; -} - /** * Aggregates all the transactional events into daily aggregates * @param logger * @param savedObjectsClient */ -export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { +export async function rollDailyData( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +): Promise { if (!savedObjectsClient) { - return; + return false; } try { @@ -58,10 +59,7 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO } = doc; const dayId = moment(timestamp).format('YYYY-MM-DD'); - const dailyId = - !viewId || viewId === MAIN_APP_DEFAULT_VIEW_ID - ? `${appId}:${dayId}` - : `${appId}:${dayId}:${viewId}`; + const dailyId = getDailyId({ dayId, appId, viewId }); const existingDoc = toCreate.get(dailyId) || @@ -103,9 +101,11 @@ export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedO } } } while (toCreate.size > 0); + return true; } catch (err) { logger.debug(`Failed to rollup transactional to daily entries`); logger.debug(err); + return false; } } @@ -125,7 +125,11 @@ async function getDailyDoc( dayId: string ): Promise { try { - return await savedObjectsClient.get(SAVED_OBJECTS_DAILY_TYPE, id); + const { attributes, version } = await savedObjectsClient.get( + SAVED_OBJECTS_DAILY_TYPE, + id + ); + return { attributes, version }; } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return { @@ -142,91 +146,3 @@ async function getDailyDoc( throw err; } } - -/** - * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days - * @param logger - * @param savedObjectsClient - */ -export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { - if (!savedObjectsClient) { - return; - } - - try { - const [ - { saved_objects: rawApplicationUsageTotals }, - { saved_objects: rawApplicationUsageDaily }, - ] = await Promise.all([ - savedObjectsClient.find({ - perPage: 10000, - type: SAVED_OBJECTS_TOTAL_TYPE, - }), - savedObjectsClient.find({ - perPage: 10000, - type: SAVED_OBJECTS_DAILY_TYPE, - filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, - }), - ]); - - const existingTotals = rawApplicationUsageTotals.reduce( - ( - acc, - { - attributes: { appId, viewId = MAIN_APP_DEFAULT_VIEW_ID, numberOfClicks, minutesOnScreen }, - } - ) => { - const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); - - return { - ...acc, - // No need to sum because there should be 1 document per appId only - [key]: { appId, viewId, numberOfClicks, minutesOnScreen }, - }; - }, - {} as Record< - string, - { appId: string; viewId: string; minutesOnScreen: number; numberOfClicks: number } - > - ); - - const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { - const { - appId, - viewId = MAIN_APP_DEFAULT_VIEW_ID, - numberOfClicks, - minutesOnScreen, - } = attributes; - const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); - const existing = acc[key] || { minutesOnScreen: 0, numberOfClicks: 0 }; - - return { - ...acc, - [key]: { - appId, - viewId, - numberOfClicks: numberOfClicks + existing.numberOfClicks, - minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, - }, - }; - }, existingTotals); - - await Promise.all([ - Object.entries(totals).length && - savedObjectsClient.bulkCreate( - Object.entries(totals).map(([id, entry]) => ({ - type: SAVED_OBJECTS_TOTAL_TYPE, - id, - attributes: entry, - })), - { overwrite: true } - ), - ...rawApplicationUsageDaily.map( - ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( - ), - ]); - } catch (err) { - logger.debug(`Failed to rollup daily entries to totals`); - logger.debug(err); - } -} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/index.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/index.ts new file mode 100644 index 0000000000000..8f3d83613aa9d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { rollDailyData } from './daily'; +export { rollTotals } from './total'; +export { serializeKey } from './utils'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts new file mode 100644 index 0000000000000..9fea955ab5d8a --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../../usage_collection/common/constants'; +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE } from '../saved_objects_types'; +import { rollTotals } from './total'; + +describe('rollTotals', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + case SAVED_OBJECTS_TOTAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); + + test('migrate some documents', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId-2:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 2.5, + numberOfClicks: 2, + }, + }, + { + id: 'appId-1:2020-01-01:viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1, + numberOfClicks: 1, + }, + }, + ], + total: 3, + page, + per_page: perPage, + }; + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1___viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + minutesOnScreen: 4, + numberOfClicks: 2, + }, + }, + { + id: 'appId-2___viewId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + viewId: 'viewId-1', + minutesOnScreen: 1, + numberOfClicks: 1, + }, + }, + ], + total: 3, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1', + attributes: { + appId: 'appId-1', + viewId: MAIN_APP_DEFAULT_VIEW_ID, + minutesOnScreen: 3.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1___viewId-1', + attributes: { + appId: 'appId-1', + viewId: 'viewId-1', + minutesOnScreen: 5.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2___viewId-1', + attributes: { + appId: 'appId-2', + viewId: 'viewId-1', + minutesOnScreen: 1.0, + numberOfClicks: 1, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2', + attributes: { + appId: 'appId-2', + viewId: MAIN_APP_DEFAULT_VIEW_ID, + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(3); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-2:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01:viewId-1' + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts new file mode 100644 index 0000000000000..e27c7b897d995 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/total.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Logger } from '@kbn/logging'; +import type { ISavedObjectsRepository } from 'kibana/server'; +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../../usage_collection/common/constants'; +import { + ApplicationUsageDaily, + ApplicationUsageTotal, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, +} from '../saved_objects_types'; +import { serializeKey } from './utils'; + +/** + * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days + * @param logger + * @param savedObjectsClient + */ +export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + ] = await Promise.all([ + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_TOTAL_TYPE, + }), + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, + }), + ]); + + const existingTotals = rawApplicationUsageTotals.reduce( + ( + acc, + { + attributes: { appId, viewId = MAIN_APP_DEFAULT_VIEW_ID, numberOfClicks, minutesOnScreen }, + } + ) => { + const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); + + return { + ...acc, + // No need to sum because there should be 1 document per appId only + [key]: { appId, viewId, numberOfClicks, minutesOnScreen }, + }; + }, + {} as Record< + string, + { appId: string; viewId: string; minutesOnScreen: number; numberOfClicks: number } + > + ); + + const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { + const { + appId, + viewId = MAIN_APP_DEFAULT_VIEW_ID, + numberOfClicks, + minutesOnScreen, + } = attributes; + const key = viewId === MAIN_APP_DEFAULT_VIEW_ID ? appId : serializeKey(appId, viewId); + const existing = acc[key] || { minutesOnScreen: 0, numberOfClicks: 0 }; + + return { + ...acc, + [key]: { + appId, + viewId, + numberOfClicks: numberOfClicks + existing.numberOfClicks, + minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, + }, + }; + }, existingTotals); + + await Promise.all([ + Object.entries(totals).length && + savedObjectsClient.bulkCreate( + Object.entries(totals).map(([id, entry]) => ({ + type: SAVED_OBJECTS_TOTAL_TYPE, + id, + attributes: entry, + })), + { overwrite: true } + ), + ...rawApplicationUsageDaily.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( + ), + ]); + } catch (err) { + logger.debug(`Failed to rollup daily entries to totals`); + logger.debug(err); + } +} diff --git a/src/plugins/vis_type_timeseries/common/field_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts similarity index 73% rename from src/plugins/vis_type_timeseries/common/field_types.ts rename to src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts index f9ebc83b4a5db..8be00e6287883 100644 --- a/src/plugins/vis_type_timeseries/common/field_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups/utils.ts @@ -6,10 +6,6 @@ * Side Public License, v 1. */ -export enum FIELD_TYPES { - BOOLEAN = 'boolean', - DATE = 'date', - GEO = 'geo_point', - NUMBER = 'number', - STRING = 'string', +export function serializeKey(appId: string, viewId: string) { + return `${appId}___${viewId}`; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 9e71b5c3b032e..f2b996f3af97a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; +import type { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; /** * Used for accumulating the totals of all the stats older than 90d @@ -17,6 +17,7 @@ export interface ApplicationUsageTotal extends SavedObjectAttributes { minutesOnScreen: number; numberOfClicks: number; } + export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; /** @@ -25,6 +26,8 @@ export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; export interface ApplicationUsageTransactional extends ApplicationUsageTotal { timestamp: string; } + +/** @deprecated transactional type is no longer used, and only preserved for backward compatibility */ export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; /** @@ -62,6 +65,7 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe }); // Type for storing ApplicationUsageTransactional (declaring empty mappings because we don't use the internal fields for query/aggregations) + // Remark: this type is deprecated and only here for BWC reasons. registerType({ name: SAVED_OBJECTS_TRANSACTIONAL_TYPE, hidden: false, diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 062d751ef454c..693e9132fe536 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -7,7 +7,7 @@ */ import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; -import { ApplicationUsageTelemetryReport } from './telemetry_application_usage_collector'; +import { ApplicationUsageTelemetryReport } from './types'; const commonSchema: MakeSchemaFrom = { appId: { type: 'keyword', _meta: { description: 'The application being tracked' } }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index 3e8434d446033..f1b21af5506e6 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -11,74 +11,99 @@ import { Collector, createUsageCollectionSetupMock, } from '../../../../usage_collection/server/usage_collection.mock'; - +import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; -import { ROLL_TOTAL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; import { registerApplicationUsageCollector, transformByApplicationViews, - ApplicationUsageViews, } from './telemetry_application_usage_collector'; -import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { - SAVED_OBJECTS_DAILY_TYPE, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './saved_objects_types'; +import { ApplicationUsageViews } from './types'; -describe('telemetry_application_usage', () => { - jest.useFakeTimers(); +import { SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE } from './saved_objects_types'; - const logger = loggingSystemMock.createLogger(); +// use fake timers to avoid triggering rollups during tests +jest.useFakeTimers(); +describe('telemetry_application_usage', () => { + let logger: ReturnType; let collector: Collector; + let usageCollectionMock: ReturnType; + let savedObjectClient: ReturnType; + let getSavedObjectClient: jest.MockedFunction<() => undefined | typeof savedObjectClient>; - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = new Collector(logger, config); - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - - const getUsageCollector = jest.fn(); const registerType = jest.fn(); const mockedFetchContext = createCollectorFetchContextMock(); - beforeAll(() => - registerApplicationUsageCollector(logger, usageCollectionMock, registerType, getUsageCollector) - ); - afterAll(() => jest.clearAllTimers()); + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + usageCollectionMock = createUsageCollectionSetupMock(); + savedObjectClient = savedObjectsRepositoryMock.create(); + getSavedObjectClient = jest.fn().mockReturnValue(savedObjectClient); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + registerApplicationUsageCollector( + logger, + usageCollectionMock, + registerType, + getSavedObjectClient + ); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); test('registered collector is set', () => { expect(collector).not.toBeUndefined(); }); test('if no savedObjectClient initialised, return undefined', async () => { + getSavedObjectClient.mockReturnValue(undefined); + expect(collector.isReady()).toBe(false); expect(await collector.fetch(mockedFetchContext)).toBeUndefined(); - jest.runTimersToTime(ROLL_INDICES_START); }); - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) + test('calls `savedObjectsClient.find` with the correct parameters', async () => { + savedObjectClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 20, + page: 0, + }); + + await collector.fetch(mockedFetchContext); + + expect(savedObjectClient.find).toHaveBeenCalledTimes(2); + + expect(savedObjectClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: SAVED_OBJECTS_TOTAL_TYPE, + }) + ); + expect(savedObjectClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: SAVED_OBJECTS_DAILY_TYPE, + }) ); - getUsageCollector.mockImplementation(() => savedObjectClient); + }); - jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + test('when savedObjectClient is initialised, return something', async () => { + savedObjectClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 20, + page: 0, + }); expect(collector.isReady()).toBe(true); expect(await collector.fetch(mockedFetchContext)).toStrictEqual({}); expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); }); - test('it only gets 10k even when there are more documents (ES limitation)', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - const total = 10000; + test('it aggregates total and daily data', async () => { savedObjectClient.find.mockImplementation(async (opts) => { switch (opts.type) { case SAVED_OBJECTS_TOTAL_TYPE: @@ -95,18 +120,6 @@ describe('telemetry_application_usage', () => { ], total: 1, } as any; - case SAVED_OBJECTS_TRANSACTIONAL_TYPE: - const doc = { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date().toISOString(), - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }; - const savedObjects = new Array(total).fill(doc); - return { saved_objects: savedObjects, total: total + 1 }; case SAVED_OBJECTS_DAILY_TYPE: return { saved_objects: [ @@ -125,122 +138,21 @@ describe('telemetry_application_usage', () => { } }); - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run - expect(await collector.fetch(mockedFetchContext)).toStrictEqual({ appId: { appId: 'appId', viewId: 'main', - clicks_total: total + 1 + 10, - clicks_7_days: total + 1, - clicks_30_days: total + 1, - clicks_90_days: total + 1, - minutes_on_screen_total: (total + 1) * 0.5 + 10, - minutes_on_screen_7_days: (total + 1) * 0.5, - minutes_on_screen_30_days: (total + 1) * 0.5, - minutes_on_screen_90_days: (total + 1) * 0.5, + clicks_total: 1 + 10, + clicks_7_days: 1, + clicks_30_days: 1, + clicks_90_days: 1, + minutes_on_screen_total: 0.5 + 10, + minutes_on_screen_7_days: 0.5, + minutes_on_screen_30_days: 0.5, + minutes_on_screen_90_days: 0.5, views: [], }, }); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - id: 'appId', - type: SAVED_OBJECTS_TOTAL_TYPE, - attributes: { - appId: 'appId', - viewId: 'main', - minutesOnScreen: 10.5, - numberOfClicks: 11, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_DAILY_TYPE, - 'appId:YYYY-MM-DD' - ); - }); - - test('old transactional data not migrated yet', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation(async (opts) => { - switch (opts.type) { - case SAVED_OBJECTS_TOTAL_TYPE: - case SAVED_OBJECTS_DAILY_TYPE: - return { saved_objects: [], total: 0 } as any; - case SAVED_OBJECTS_TRANSACTIONAL_TYPE: - return { - saved_objects: [ - { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 0.5, - numberOfClicks: 1, - }, - }, - { - id: 'test-id-2', - attributes: { - appId: 'appId', - viewId: 'main', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 2, - numberOfClicks: 2, - }, - }, - { - id: 'test-id-3', - attributes: { - appId: 'appId', - viewId: 'viewId-1', - timestamp: new Date(0).toISOString(), - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }, - ], - total: 1, - }; - } - }); - - getUsageCollector.mockImplementation(() => savedObjectClient); - - expect(await collector.fetch(mockedFetchContext)).toStrictEqual({ - appId: { - appId: 'appId', - viewId: 'main', - clicks_total: 3, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 2.5, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - views: [ - { - appId: 'appId', - viewId: 'viewId-1', - clicks_total: 1, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 1, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }, - ], - }, - }); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index ee1b42e61a6ca..a01f1bca4f0e0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -11,57 +11,21 @@ import { timer } from 'rxjs'; import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; -import { serializeKey } from './rollups'; - import { ApplicationUsageDaily, ApplicationUsageTotal, - ApplicationUsageTransactional, registerMappings, SAVED_OBJECTS_DAILY_TYPE, SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, } from './saved_objects_types'; import { applicationUsageSchema } from './schema'; -import { rollDailyData, rollTotals } from './rollups'; +import { rollTotals, rollDailyData, serializeKey } from './rollups'; import { ROLL_TOTAL_INDICES_INTERVAL, ROLL_DAILY_INDICES_INTERVAL, ROLL_INDICES_START, } from './constants'; - -export interface ApplicationViewUsage { - appId: string; - viewId: string; - clicks_total: number; - clicks_7_days: number; - clicks_30_days: number; - clicks_90_days: number; - minutes_on_screen_total: number; - minutes_on_screen_7_days: number; - minutes_on_screen_30_days: number; - minutes_on_screen_90_days: number; -} - -export interface ApplicationUsageViews { - [serializedKey: string]: ApplicationViewUsage; -} - -export interface ApplicationUsageTelemetryReport { - [appId: string]: { - appId: string; - viewId: string; - clicks_total: number; - clicks_7_days: number; - clicks_30_days: number; - clicks_90_days: number; - minutes_on_screen_total: number; - minutes_on_screen_7_days: number; - minutes_on_screen_30_days: number; - minutes_on_screen_90_days: number; - views?: ApplicationViewUsage[]; - }; -} +import { ApplicationUsageTelemetryReport, ApplicationUsageViews } from './types'; export const transformByApplicationViews = ( report: ApplicationUsageViews @@ -92,6 +56,21 @@ export function registerApplicationUsageCollector( ) { registerMappings(registerType); + timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => + rollTotals(logger, getSavedObjectsClient()) + ); + + const dailyRollingSub = timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe( + async () => { + const success = await rollDailyData(logger, getSavedObjectsClient()); + // we only need to roll the transactional documents once to assure BWC + // once we rolling succeeds, we can stop. + if (success) { + dailyRollingSub.unsubscribe(); + } + } + ); + const collector = usageCollection.makeUsageCollector( { type: 'application_usage', @@ -105,7 +84,6 @@ export function registerApplicationUsageCollector( const [ { saved_objects: rawApplicationUsageTotals }, { saved_objects: rawApplicationUsageDaily }, - { saved_objects: rawApplicationUsageTransactional }, ] = await Promise.all([ savedObjectsClient.find({ type: SAVED_OBJECTS_TOTAL_TYPE, @@ -115,10 +93,6 @@ export function registerApplicationUsageCollector( type: SAVED_OBJECTS_DAILY_TYPE, perPage: 10000, // We can have up to 44 apps * 91 days = 4004 docs. This limit is OK }), - savedObjectsClient.find({ - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - perPage: 10000, // If we have more than those, we won't report the rest (they'll be rolled up to the daily soon enough to become a problem) - }), ]); const applicationUsageFromTotals = rawApplicationUsageTotals.reduce( @@ -156,10 +130,7 @@ export function registerApplicationUsageCollector( const nowMinus30 = moment().subtract(30, 'days'); const nowMinus90 = moment().subtract(90, 'days'); - const applicationUsage = [ - ...rawApplicationUsageDaily, - ...rawApplicationUsageTransactional, - ].reduce( + const applicationUsage = rawApplicationUsageDaily.reduce( ( acc, { @@ -224,11 +195,4 @@ export function registerApplicationUsageCollector( ); usageCollection.registerCollector(collector); - - timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => - rollDailyData(logger, getSavedObjectsClient()) - ); - timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => - rollTotals(logger, getSavedObjectsClient()) - ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.ts new file mode 100644 index 0000000000000..bef835e922d8d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/types.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. + */ + +export interface ApplicationViewUsage { + appId: string; + viewId: string; + clicks_total: number; + clicks_7_days: number; + clicks_30_days: number; + clicks_90_days: number; + minutes_on_screen_total: number; + minutes_on_screen_7_days: number; + minutes_on_screen_30_days: number; + minutes_on_screen_90_days: number; +} + +export interface ApplicationUsageViews { + [serializedKey: string]: ApplicationViewUsage; +} + +export interface ApplicationUsageTelemetryReport { + [appId: string]: { + appId: string; + viewId: string; + clicks_total: number; + clicks_7_days: number; + clicks_30_days: number; + clicks_90_days: number; + minutes_on_screen_total: number; + minutes_on_screen_7_days: number; + minutes_on_screen_30_days: number; + minutes_on_screen_90_days: number; + views?: ApplicationViewUsage[]; + }; +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.ts index 52ba793882a1d..42363f71ef87a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/get_saved_object_counts.ts @@ -58,6 +58,7 @@ export async function getSavedObjectsCounts( }; const { body } = await esClient.search(savedObjectCountSearchParams); const buckets: Array<{ key: string; doc_count: number }> = + // @ts-expect-error @elastic/elasticsearch Aggregate does not include `buckets` body.aggregations?.types?.buckets || []; // Initialise the object with all zeros for all the types diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 5959eb6aca4d4..41bb7c07bda7e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -412,4 +412,8 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableInspectEsQueries': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index fd63bb5bcaf43..c4a70f5065d8e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -31,6 +31,7 @@ export interface UsageStats { 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; 'observability:enableAlertingExperience': boolean; + 'observability:enableInspectEsQueries': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/security_oss/server/check_cluster_data.test.ts b/src/plugins/security_oss/server/check_cluster_data.test.ts index 0670eb3116b07..9e9459a68754c 100644 --- a/src/plugins/security_oss/server/check_cluster_data.test.ts +++ b/src/plugins/security_oss/server/check_cluster_data.test.ts @@ -27,6 +27,7 @@ describe('checkClusterForUserData', () => { it('returns false if data only exists in system indices', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); esClient.cat.indices.mockResolvedValue( + // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { @@ -55,6 +56,7 @@ describe('checkClusterForUserData', () => { it('returns true if data exists in non-system indices', async () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); esClient.cat.indices.mockResolvedValue( + // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { @@ -85,6 +87,7 @@ describe('checkClusterForUserData', () => { ) .mockRejectedValueOnce(new Error('something terrible happened')) .mockResolvedValueOnce( + // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { @@ -95,6 +98,7 @@ describe('checkClusterForUserData', () => { }) ) .mockResolvedValueOnce( + // @ts-expect-error @elastic/elasticsearch ES types don't support array response format elasticsearchServiceMock.createApiResponse({ body: [ { diff --git a/src/plugins/security_oss/server/check_cluster_data.ts b/src/plugins/security_oss/server/check_cluster_data.ts index c8c30196b485c..19a4145333dd0 100644 --- a/src/plugins/security_oss/server/check_cluster_data.ts +++ b/src/plugins/security_oss/server/check_cluster_data.ts @@ -14,17 +14,15 @@ export const createClusterDataCheck = () => { return async function doesClusterHaveUserData(esClient: ElasticsearchClient, log: Logger) { if (!clusterHasUserData) { try { - const indices = await esClient.cat.indices< - Array<{ index: string; ['docs.count']: string }> - >({ + const indices = await esClient.cat.indices({ format: 'json', h: ['index', 'docs.count'], }); clusterHasUserData = indices.body.some((indexCount) => { const isInternalIndex = - indexCount.index.startsWith('.') || indexCount.index.startsWith('kibana_sample_'); + indexCount.index?.startsWith('.') || indexCount.index?.startsWith('kibana_sample_'); - return !isInternalIndex && parseInt(indexCount['docs.count'], 10) > 0; + return !isInternalIndex && parseInt(indexCount['docs.count']!, 10) > 0; }); } catch (e) { log.warn(`Error encountered while checking cluster for user data: ${e}`); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 451b3ffe91535..3cc054bdcac88 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8032,6 +8032,12 @@ "_meta": { "description": "Non-default value of setting." } + }, + "observability:enableInspectEsQueries": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } } } }, diff --git a/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts index 437d76fe7ccf2..3f93bde1e7e62 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts @@ -8,22 +8,6 @@ import { ElasticsearchClient } from 'src/core/server'; -// This can be removed when the ES client improves the types -export interface ESClusterInfo { - cluster_uuid: string; - cluster_name: string; - version: { - number: string; - build_flavor?: string; - build_type?: string; - build_hash?: string; - build_date?: string; - build_snapshot?: boolean; - lucene_version?: string; - minimum_wire_compatibility_version?: string; - minimum_index_compatibility_version?: string; - }; -} /** * Get the cluster info from the connected cluster. * @@ -32,6 +16,6 @@ export interface ESClusterInfo { * @param {function} esClient The asInternalUser handler (exposed for testing) */ export async function getClusterInfo(esClient: ElasticsearchClient) { - const { body } = await esClient.info(); + const { body } = await esClient.info(); return body; } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts index 42ccbcc46c462..c79c46072e11b 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.ts @@ -261,14 +261,16 @@ export async function getDataTelemetry(esClient: ElasticsearchClient) { const indices = indexNames.map((name) => { const baseIndexInfo = { name, - isECS: !!indexMappings[name]?.mappings?.properties.ecs?.properties.version?.type, + isECS: !!indexMappings[name]?.mappings?.properties?.ecs?.properties?.version?.type, shipper: indexMappings[name]?.mappings?._meta?.beat, packageName: indexMappings[name]?.mappings?._meta?.package?.name, managedBy: indexMappings[name]?.mappings?._meta?.managed_by, dataStreamDataset: - indexMappings[name]?.mappings?.properties.data_stream?.properties.dataset?.value, + // @ts-expect-error @elastic/elasticsearch PropertyBase doesn't decalre value + indexMappings[name]?.mappings?.properties?.data_stream?.properties?.dataset?.value, dataStreamType: - indexMappings[name]?.mappings?.properties.data_stream?.properties.type?.value, + // @ts-expect-error @elastic/elasticsearch PropertyBase doesn't decalre value + indexMappings[name]?.mappings?.properties?.data_stream?.properties?.type?.value, }; const stats = (indexStats?.indices || {})[name]; diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index 47c6736ff9aea..edf8dbb30809b 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -7,6 +7,7 @@ */ import { merge, omit } from 'lodash'; +import type { estypes } from '@elastic/elasticsearch'; import { getLocalStats, handleLocalStats } from './get_local_stats'; import { @@ -34,35 +35,33 @@ function mockGetLocalStats(clusterInfo: any, clusterStats: any) { esClient.cluster.stats // @ts-expect-error we only care about the response body .mockResolvedValue({ body: { ...clusterStats } }); - esClient.nodes.usage.mockResolvedValue( + esClient.nodes.usage.mockResolvedValue({ // @ts-expect-error we only care about the response body - { - body: { - cluster_name: 'testCluster', - nodes: { - some_node_id: { - timestamp: 1588617023177, - since: 1588616945163, - rest_actions: { - nodes_usage_action: 1, - create_index_action: 1, - document_get_action: 1, - search_action: 19, - nodes_info_action: 36, + body: { + cluster_name: 'testCluster', + nodes: { + some_node_id: { + timestamp: 1588617023177, + since: 1588616945163, + rest_actions: { + nodes_usage_action: 1, + create_index_action: 1, + document_get_action: 1, + search_action: 19, + nodes_info_action: 36, + }, + aggregations: { + scripted_metric: { + other: 7, }, - aggregations: { - terms: { - bytes: 2, - }, - scripted_metric: { - other: 7, - }, + terms: { + bytes: 2, }, }, }, }, - } - ); + }, + }); // @ts-expect-error we only care about the response body esClient.indices.getMapping.mockResolvedValue({ body: { mappings: {} } }); // @ts-expect-error we only care about the response body @@ -188,7 +187,7 @@ describe('get_local_stats', () => { describe('handleLocalStats', () => { it('returns expected object without xpack or kibana data', () => { const result = handleLocalStats( - clusterInfo, + clusterInfo as estypes.RootNodeInfoResponse, clusterStatsWithNodesUsage, void 0, void 0, @@ -205,7 +204,7 @@ describe('get_local_stats', () => { it('returns expected object with xpack', () => { const result = handleLocalStats( - clusterInfo, + clusterInfo as estypes.RootNodeInfoResponse, clusterStatsWithNodesUsage, void 0, void 0, diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index 710d836576d10..67f9ebb8ff3e4 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -6,11 +6,12 @@ * Side Public License, v 1. */ +import type { estypes } from '@elastic/elasticsearch'; import { StatsGetter, StatsCollectionContext, } from 'src/plugins/telemetry_collection_manager/server'; -import { getClusterInfo, ESClusterInfo } from './get_cluster_info'; +import { getClusterInfo } from './get_cluster_info'; import { getClusterStats } from './get_cluster_stats'; import { getKibana, handleKibanaStats, KibanaUsageStats } from './get_kibana'; import { getNodesUsage } from './get_nodes_usage'; @@ -27,7 +28,7 @@ import { getDataTelemetry, DATA_TELEMETRY_ID, DataTelemetryPayload } from './get */ export function handleLocalStats( // eslint-disable-next-line @typescript-eslint/naming-convention - { cluster_name, cluster_uuid, version }: ESClusterInfo, + { cluster_name, cluster_uuid, version }: estypes.RootNodeInfoResponse, { _nodes, cluster_name: clusterName, ...clusterStats }: any, kibana: KibanaUsageStats | undefined, dataTelemetry: DataTelemetryPayload | undefined, diff --git a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts index 18c6d16447238..e46d4be540734 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_nodes_usage.ts @@ -16,7 +16,7 @@ export interface NodeAggregation { // we set aggregations as an optional type because it was only added in v7.8.0 export interface NodeObj { node_id?: string; - timestamp: number; + timestamp: number | string; since: number; rest_actions: { [key: string]: number; @@ -46,9 +46,10 @@ export type NodesUsageGetter = ( export async function fetchNodesUsage( esClient: ElasticsearchClient ): Promise { - const { body } = await esClient.nodes.usage({ + const { body } = await esClient.nodes.usage({ timeout: TIMEOUT, }); + // @ts-expect-error TODO: Does the client parse `timestamp` to a Date object? Expected a number return body; } diff --git a/src/plugins/timelion/server/plugin.ts b/src/plugins/timelion/server/plugin.ts index 66348c572117d..226a978fe5d88 100644 --- a/src/plugins/timelion/server/plugin.ts +++ b/src/plugins/timelion/server/plugin.ts @@ -47,6 +47,7 @@ export class TimelionPlugin implements Plugin { core.capabilities.registerProvider(() => ({ timelion: { save: true, + show: true, }, })); core.savedObjects.registerType(timelionSheetSavedObjectType); diff --git a/src/plugins/timelion/server/saved_objects/timelion_sheet.ts b/src/plugins/timelion/server/saved_objects/timelion_sheet.ts index 52d7f59a7c734..231e049280bb1 100644 --- a/src/plugins/timelion/server/saved_objects/timelion_sheet.ts +++ b/src/plugins/timelion/server/saved_objects/timelion_sheet.ts @@ -12,6 +12,20 @@ export const timelionSheetSavedObjectType: SavedObjectsType = { name: 'timelion-sheet', hidden: false, namespaceType: 'single', + management: { + icon: 'visTimelion', + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + getInAppUrl(obj) { + return { + path: `/app/timelion#/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'timelion.show', + }; + }, + }, mappings: { properties: { description: { type: 'text' }, diff --git a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js b/src/plugins/usage_collection/common/application_usage.ts similarity index 50% rename from src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js rename to src/plugins/usage_collection/common/application_usage.ts index af8404eb6da92..c9dd489000d35 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js +++ b/src/plugins/usage_collection/common/application_usage.ts @@ -6,12 +6,18 @@ * Side Public License, v 1. */ -import React, { useContext } from 'react'; -import { CoreStartContext } from '../contexts/query_input_bar_context'; -import { QueryStringInput } from '../../../../../plugins/data/public'; +import { MAIN_APP_DEFAULT_VIEW_ID } from './constants'; -export function QueryBarWrapper(props) { - const coreStartContext = useContext(CoreStartContext); - - return ; -} +export const getDailyId = ({ + appId, + dayId, + viewId, +}: { + viewId: string; + appId: string; + dayId: string; +}) => { + return !viewId || viewId === MAIN_APP_DEFAULT_VIEW_ID + ? `${appId}:${dayId}` + : `${appId}:${dayId}:${viewId}`; +}; diff --git a/src/plugins/usage_collection/server/report/schema.ts b/src/plugins/usage_collection/server/report/schema.ts index 93203a33cd1e1..350ec8d90e765 100644 --- a/src/plugins/usage_collection/server/report/schema.ts +++ b/src/plugins/usage_collection/server/report/schema.ts @@ -9,6 +9,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { METRIC_TYPE } from '@kbn/analytics'; +const applicationUsageReportSchema = schema.object({ + minutesOnScreen: schema.number(), + numberOfClicks: schema.number(), + appId: schema.string(), + viewId: schema.string(), +}); + export const reportSchema = schema.object({ reportVersion: schema.maybe(schema.oneOf([schema.literal(3)])), userAgent: schema.maybe( @@ -38,17 +45,8 @@ export const reportSchema = schema.object({ }) ) ), - application_usage: schema.maybe( - schema.recordOf( - schema.string(), - schema.object({ - minutesOnScreen: schema.number(), - numberOfClicks: schema.number(), - appId: schema.string(), - viewId: schema.string(), - }) - ) - ), + application_usage: schema.maybe(schema.recordOf(schema.string(), applicationUsageReportSchema)), }); export type ReportSchemaType = TypeOf; +export type ApplicationUsageReport = TypeOf; diff --git a/src/plugins/usage_collection/server/report/store_application_usage.test.ts b/src/plugins/usage_collection/server/report/store_application_usage.test.ts new file mode 100644 index 0000000000000..c4c9e5746e6cb --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_application_usage.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; +import { getDailyId } from '../../common/application_usage'; +import { storeApplicationUsage } from './store_application_usage'; +import { ApplicationUsageReport } from './schema'; + +const createReport = (parts: Partial): ApplicationUsageReport => ({ + appId: 'appId', + viewId: 'viewId', + numberOfClicks: 0, + minutesOnScreen: 0, + ...parts, +}); + +describe('storeApplicationUsage', () => { + let repository: ReturnType; + let timestamp: Date; + + beforeEach(() => { + repository = savedObjectsRepositoryMock.create(); + timestamp = new Date(); + }); + + it('does not call `repository.incrementUsageCounters` when the report list is empty', async () => { + await storeApplicationUsage(repository, [], timestamp); + expect(repository.incrementCounter).not.toHaveBeenCalled(); + }); + + it('calls `repository.incrementUsageCounters` with the correct parameters', async () => { + const report = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 2, + minutesOnScreen: 5, + }); + + await storeApplicationUsage(repository, [report], timestamp); + + expect(repository.incrementCounter).toHaveBeenCalledTimes(1); + + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams(report, timestamp) + ); + }); + + it('aggregates reports with the same appId/viewId tuple', async () => { + const report1 = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 2, + minutesOnScreen: 5, + }); + const report2 = createReport({ + appId: 'app1', + viewId: 'view2', + numberOfClicks: 1, + minutesOnScreen: 7, + }); + const report3 = createReport({ + appId: 'app1', + viewId: 'view1', + numberOfClicks: 3, + minutesOnScreen: 9, + }); + + await storeApplicationUsage(repository, [report1, report2, report3], timestamp); + + expect(repository.incrementCounter).toHaveBeenCalledTimes(2); + + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams( + { + appId: 'app1', + viewId: 'view1', + numberOfClicks: report1.numberOfClicks + report3.numberOfClicks, + minutesOnScreen: report1.minutesOnScreen + report3.minutesOnScreen, + }, + timestamp + ) + ); + expect(repository.incrementCounter).toHaveBeenCalledWith( + ...expectedIncrementParams(report2, timestamp) + ); + }); +}); + +const expectedIncrementParams = ( + { appId, viewId, minutesOnScreen, numberOfClicks }: ApplicationUsageReport, + timestamp: Date +) => { + const dayId = moment(timestamp).format('YYYY-MM-DD'); + return [ + 'application_usage_daily', + getDailyId({ appId, viewId, dayId }), + [ + { fieldName: 'numberOfClicks', incrementBy: numberOfClicks }, + { fieldName: 'minutesOnScreen', incrementBy: minutesOnScreen }, + ], + { + upsertAttributes: { + appId, + viewId, + timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(), + }, + }, + ]; +}; diff --git a/src/plugins/usage_collection/server/report/store_application_usage.ts b/src/plugins/usage_collection/server/report/store_application_usage.ts new file mode 100644 index 0000000000000..2058b054fda8c --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_application_usage.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { Writable } from '@kbn/utility-types'; +import { ISavedObjectsRepository } from 'src/core/server'; +import { ApplicationUsageReport } from './schema'; +import { getDailyId } from '../../common/application_usage'; + +type WritableApplicationUsageReport = Writable; + +export const storeApplicationUsage = async ( + repository: ISavedObjectsRepository, + appUsages: ApplicationUsageReport[], + timestamp: Date +) => { + if (!appUsages.length) { + return; + } + + const dayId = getDayId(timestamp); + const aggregatedReports = aggregateAppUsages(appUsages); + + return Promise.allSettled( + aggregatedReports.map(async (report) => incrementUsageCounters(repository, report, dayId)) + ); +}; + +const aggregateAppUsages = (appUsages: ApplicationUsageReport[]) => { + return [ + ...appUsages + .reduce((map, appUsage) => { + const key = getKey(appUsage); + const aggregated: WritableApplicationUsageReport = map.get(key) ?? { + appId: appUsage.appId, + viewId: appUsage.viewId, + minutesOnScreen: 0, + numberOfClicks: 0, + }; + + aggregated.minutesOnScreen += appUsage.minutesOnScreen; + aggregated.numberOfClicks += appUsage.numberOfClicks; + + map.set(key, aggregated); + return map; + }, new Map()) + .values(), + ]; +}; + +const incrementUsageCounters = ( + repository: ISavedObjectsRepository, + { appId, viewId, numberOfClicks, minutesOnScreen }: WritableApplicationUsageReport, + dayId: string +) => { + const dailyId = getDailyId({ appId, viewId, dayId }); + + return repository.incrementCounter( + 'application_usage_daily', + dailyId, + [ + { fieldName: 'numberOfClicks', incrementBy: numberOfClicks }, + { fieldName: 'minutesOnScreen', incrementBy: minutesOnScreen }, + ], + { + upsertAttributes: { + appId, + viewId, + timestamp: getTimestamp(dayId), + }, + } + ); +}; + +const getKey = ({ viewId, appId }: ApplicationUsageReport) => `${appId}___${viewId}`; + +const getDayId = (timestamp: Date) => moment(timestamp).format('YYYY-MM-DD'); + +const getTimestamp = (dayId: string) => { + // Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects + return moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(); +}; diff --git a/src/plugins/usage_collection/server/report/store_report.test.mocks.ts b/src/plugins/usage_collection/server/report/store_report.test.mocks.ts new file mode 100644 index 0000000000000..d151e7d7a5ddd --- /dev/null +++ b/src/plugins/usage_collection/server/report/store_report.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 storeApplicationUsageMock = jest.fn(); +jest.doMock('./store_application_usage', () => ({ + storeApplicationUsage: storeApplicationUsageMock, +})); diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index 7174a54067246..dfcdd1f8e7e42 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { storeApplicationUsageMock } from './store_report.test.mocks'; + import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { storeReport } from './store_report'; import { ReportSchemaType } from './schema'; @@ -16,8 +18,17 @@ describe('store_report', () => { const momentTimestamp = moment(); const date = momentTimestamp.format('DDMMYYYY'); + let repository: ReturnType; + + beforeEach(() => { + repository = savedObjectsRepositoryMock.create(); + }); + + afterEach(() => { + storeApplicationUsageMock.mockReset(); + }); + test('stores report for all types of data', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); const report: ReportSchemaType = { reportVersion: 3, userAgent: { @@ -53,9 +64,9 @@ describe('store_report', () => { }, }, }; - await storeReport(savedObjectClient, report); + await storeReport(repository, report); - expect(savedObjectClient.create).toHaveBeenCalledWith( + expect(repository.create).toHaveBeenCalledWith( 'ui-metric', { count: 1 }, { @@ -63,51 +74,45 @@ describe('store_report', () => { overwrite: true, } ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 1, 'ui-metric', 'test-app-name:test-event-name', [{ fieldName: 'count', incrementBy: 3 }] ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 2, 'ui-counter', `test-app-name:${date}:${METRIC_TYPE.LOADED}:test-event-name`, [{ fieldName: 'count', incrementBy: 1 }] ); - expect(savedObjectClient.incrementCounter).toHaveBeenNthCalledWith( + expect(repository.incrementCounter).toHaveBeenNthCalledWith( 3, 'ui-counter', `test-app-name:${date}:${METRIC_TYPE.CLICK}:test-event-name`, [{ fieldName: 'count', incrementBy: 2 }] ); - expect(savedObjectClient.bulkCreate).toHaveBeenNthCalledWith(1, [ - { - type: 'application_usage_transactional', - attributes: { - numberOfClicks: 3, - minutesOnScreen: 10, - appId: 'appId', - viewId: 'appId_view', - timestamp: expect.any(Date), - }, - }, - ]); + + expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); + expect(storeApplicationUsageMock).toHaveBeenCalledWith( + repository, + Object.values(report.application_usage as Record), + expect.any(Date) + ); }); test('it should not fail if nothing to store', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); const report: ReportSchemaType = { reportVersion: 3, userAgent: void 0, uiCounter: void 0, application_usage: void 0, }; - await storeReport(savedObjectClient, report); + await storeReport(repository, report); - expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); - expect(savedObjectClient.incrementCounter).not.toHaveBeenCalled(); - expect(savedObjectClient.create).not.toHaveBeenCalled(); - expect(savedObjectClient.create).not.toHaveBeenCalled(); + expect(repository.bulkCreate).not.toHaveBeenCalled(); + expect(repository.incrementCounter).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts index c3e04990d5793..0545a54792d45 100644 --- a/src/plugins/usage_collection/server/report/store_report.ts +++ b/src/plugins/usage_collection/server/report/store_report.ts @@ -10,6 +10,7 @@ import { ISavedObjectsRepository } from 'src/core/server'; import moment from 'moment'; import { chain, sumBy } from 'lodash'; import { ReportSchemaType } from './schema'; +import { storeApplicationUsage } from './store_application_usage'; export async function storeReport( internalRepository: ISavedObjectsRepository, @@ -17,11 +18,11 @@ export async function storeReport( ) { const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : []; const userAgents = report.userAgent ? Object.entries(report.userAgent) : []; - const appUsage = report.application_usage ? Object.values(report.application_usage) : []; + const appUsages = report.application_usage ? Object.values(report.application_usage) : []; const momentTimestamp = moment(); - const timestamp = momentTimestamp.toDate(); const date = momentTimestamp.format('DDMMYYYY'); + const timestamp = momentTimestamp.toDate(); return Promise.allSettled([ // User Agent @@ -64,21 +65,6 @@ export async function storeReport( ]; }), // Application Usage - ...[ - (async () => { - if (!appUsage.length) return []; - const { saved_objects: savedObjects } = await internalRepository.bulkCreate( - appUsage.map((metric) => ({ - type: 'application_usage_transactional', - attributes: { - ...metric, - timestamp, - }, - })) - ); - - return savedObjects; - })(), - ], + storeApplicationUsage(internalRepository, appUsages, timestamp), ]); } diff --git a/src/plugins/vis_type_metric/public/metric_vis_type.ts b/src/plugins/vis_type_metric/public/metric_vis_type.ts index 732205fb31eab..9e2e248c6ccd5 100644 --- a/src/plugins/vis_type_metric/public/metric_vis_type.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_type.ts @@ -63,6 +63,7 @@ export const createMetricVisTypeDefinition = (): VisTypeDefinition => '!moving_avg', '!cumulative_sum', '!geo_bounds', + '!filtered_metric', ], aggSettings: { top_hits: { diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts index 020a317e0471b..2f30faa8e9a89 100644 --- a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts @@ -50,7 +50,7 @@ export const tableVisLegacyTypeDefinition: VisTypeDefinition = { title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.metricTitle', { defaultMessage: 'Metric', }), - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], aggSettings: { top_hits: { allowStrings: true, diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index 774742f02dde5..d645af3180b08 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -46,7 +46,7 @@ export const tableVisTypeDefinition: VisTypeDefinition = { title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.metricTitle', { defaultMessage: 'Metric', }), - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], aggSettings: { top_hits: { allowStrings: true, diff --git a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts index 6f2c6112064a9..4052ecbe21997 100644 --- a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts +++ b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts @@ -51,6 +51,7 @@ export const tagCloudVisTypeDefinition = { '!derivative', '!geo_bounds', '!geo_centroid', + '!filtered_metric', ], defaults: [{ schema: 'metric', type: 'count' }], }, diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts deleted file mode 100644 index c4da2085855e6..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts +++ /dev/null @@ -1,35 +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 { extractIndexPatterns } from './extract_index_patterns'; -import { PanelSchema } from './types'; - -describe('extractIndexPatterns(vis)', () => { - let panel: PanelSchema; - - beforeEach(() => { - panel = { - index_pattern: '*', - series: [ - { - override_index_pattern: 1, - series_index_pattern: 'example-1-*', - }, - { - override_index_pattern: 1, - series_index_pattern: 'example-2-*', - }, - ], - annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], - } as PanelSchema; - }); - - test('should return index patterns', () => { - expect(extractIndexPatterns(panel, '')).toEqual(['*', 'example-1-*', 'example-2-*', 'notes-*']); - }); -}); diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts deleted file mode 100644 index c716ae7abb821..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { uniq } from 'lodash'; -import { PanelSchema } from '../common/types'; - -export function extractIndexPatterns( - panel: PanelSchema, - defaultIndex?: PanelSchema['default_index_pattern'] -) { - const patterns: string[] = []; - - if (panel.index_pattern) { - patterns.push(panel.index_pattern); - } - - panel.series.forEach((series) => { - const indexPattern = series.series_index_pattern; - if (indexPattern && series.override_index_pattern) { - patterns.push(indexPattern); - } - }); - - if (panel.annotations) { - panel.annotations.forEach((item) => { - const indexPattern = item.index_pattern; - if (indexPattern) { - patterns.push(indexPattern); - } - }); - } - - if (patterns.length === 0 && defaultIndex) { - patterns.push(defaultIndex); - } - - return uniq(patterns).sort(); -} diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.test.ts b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts new file mode 100644 index 0000000000000..d1036aab2dc3e --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { toSanitizedFieldType } from './fields_utils'; +import type { FieldSpec, RuntimeField } from '../../data/common'; + +describe('fields_utils', () => { + describe('toSanitizedFieldType', () => { + const mockedField = { + lang: 'lang', + conflictDescriptions: {}, + aggregatable: true, + name: 'name', + type: 'type', + esTypes: ['long', 'geo'], + } as FieldSpec; + + test('should sanitize fields ', async () => { + const fields = [mockedField] as FieldSpec[]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` + Array [ + Object { + "label": "name", + "name": "name", + "type": "type", + }, + ] + `); + }); + + test('should filter runtime fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + runtimeField: {} as RuntimeField, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter non-aggregatable fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + aggregatable: false, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter nested fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + subType: { + nested: { + path: 'path', + }, + }, + }, + ]; + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.ts b/src/plugins/vis_type_timeseries/common/fields_utils.ts new file mode 100644 index 0000000000000..04499d5320ab8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FieldSpec } from '../../data/common'; +import { isNestedField } from '../../data/common'; +import { SanitizedFieldType } from './types'; + +export const toSanitizedFieldType = (fields: FieldSpec[]) => { + return fields + .filter( + (field) => + // Make sure to only include mapped fields, e.g. no index pattern runtime fields + !field.runtimeField && field.aggregatable && !isNestedField(field) + ) + .map( + (field) => + ({ + name: field.name, + label: field.customLabel ?? field.name, + type: field.type, + } as SanitizedFieldType) + ); +}; diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts new file mode 100644 index 0000000000000..515fadffb6b32 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + extractIndexPatternValues, + isStringTypeIndexPattern, + fetchIndexPattern, +} from './index_patterns_utils'; +import { PanelSchema } from './types'; +import { IndexPattern, IndexPatternsService } from '../../data/common'; + +describe('isStringTypeIndexPattern', () => { + test('should returns true on string-based index', () => { + expect(isStringTypeIndexPattern('index')).toBeTruthy(); + }); + test('should returns false on object-based index', () => { + expect(isStringTypeIndexPattern({ id: 'id' })).toBeFalsy(); + }); +}); + +describe('extractIndexPatterns', () => { + let panel: PanelSchema; + + beforeEach(() => { + panel = { + index_pattern: '*', + series: [ + { + override_index_pattern: 1, + series_index_pattern: 'example-1-*', + }, + { + override_index_pattern: 1, + series_index_pattern: 'example-2-*', + }, + ], + annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], + } as PanelSchema; + }); + + test('should return index patterns', () => { + expect(extractIndexPatternValues(panel, '')).toEqual([ + '*', + 'example-1-*', + 'example-2-*', + 'notes-*', + ]); + }); +}); + +describe('fetchIndexPattern', () => { + let mockedIndices: IndexPattern[] | []; + let indexPatternsService: IndexPatternsService; + + beforeEach(() => { + mockedIndices = []; + + indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + }); + + test('should return default index on no input value', async () => { + const value = await fetchIndexPattern('', indexPatternsService); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern({ id: 'indexId' }, indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts new file mode 100644 index 0000000000000..398d1c30ed5a7 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.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 { uniq } from 'lodash'; +import { PanelSchema, IndexPatternValue, FetchedIndexPattern } from '../common/types'; +import { IndexPatternsService } from '../../data/common'; + +export const isStringTypeIndexPattern = ( + indexPatternValue: IndexPatternValue +): indexPatternValue is string => typeof indexPatternValue === 'string'; + +export const getIndexPatternKey = (indexPatternValue: IndexPatternValue) => + isStringTypeIndexPattern(indexPatternValue) ? indexPatternValue : indexPatternValue?.id ?? ''; + +export const extractIndexPatternValues = ( + panel: PanelSchema, + defaultIndex?: PanelSchema['default_index_pattern'] +) => { + const patterns: IndexPatternValue[] = []; + + if (panel.index_pattern) { + patterns.push(panel.index_pattern); + } + + panel.series.forEach((series) => { + const indexPattern = series.series_index_pattern; + if (indexPattern && series.override_index_pattern) { + patterns.push(indexPattern); + } + }); + + if (panel.annotations) { + panel.annotations.forEach((item) => { + const indexPattern = item.index_pattern; + if (indexPattern) { + patterns.push(indexPattern); + } + }); + } + + if (patterns.length === 0 && defaultIndex) { + patterns.push(defaultIndex); + } + + return uniq(patterns).sort(); +}; + +export const fetchIndexPattern = async ( + indexPatternValue: IndexPatternValue | undefined, + indexPatternsService: Pick +): Promise => { + let indexPattern: FetchedIndexPattern['indexPattern']; + let indexPatternString: string = ''; + + if (!indexPatternValue) { + indexPattern = await indexPatternsService.getDefault(); + } else { + if (isStringTypeIndexPattern(indexPatternValue)) { + indexPattern = (await indexPatternsService.find(indexPatternValue)).find( + (index) => index.title === indexPatternValue + ); + + if (!indexPattern) { + indexPatternString = indexPatternValue; + } + } else if (indexPatternValue.id) { + indexPattern = await indexPatternsService.get(indexPatternValue.id); + } + } + + return { + indexPattern, + indexPatternString: indexPattern?.title ?? indexPatternString, + }; +}; diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 7d93232f310c9..1fe6196ad545b 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -13,10 +13,12 @@ import { seriesItems, visPayloadSchema, fieldObject, + indexPattern, annotationsItems, } from './vis_schema'; import { PANEL_TYPES } from './panel_types'; import { TimeseriesUIRestrictions } from './ui_restrictions'; +import { IndexPattern } from '../../data/common'; export type AnnotationItemsSchema = TypeOf; export type SeriesItemsSchema = TypeOf; @@ -24,6 +26,12 @@ export type MetricsItemsSchema = TypeOf; export type PanelSchema = TypeOf; export type VisPayload = TypeOf; export type FieldObject = TypeOf; +export type IndexPatternValue = TypeOf; + +export interface FetchedIndexPattern { + indexPattern: IndexPattern | undefined | null; + indexPatternString: string | undefined; +} export interface PanelData { id: string; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index a6bf70948bc1b..297b021fa9e77 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -28,7 +28,7 @@ const numberOptional = schema.maybe(schema.number()); const queryObject = schema.object({ language: schema.string(), - query: schema.string(), + query: schema.oneOf([schema.string(), schema.any()]), }); const stringOrNumberOptionalNullable = schema.nullable( schema.oneOf([stringOptionalNullable, numberOptional]) @@ -37,6 +37,13 @@ const numberOptionalOrEmptyString = schema.maybe( schema.oneOf([numberOptional, schema.literal('')]) ); +export const indexPattern = schema.oneOf([ + schema.maybe(schema.string()), + schema.object({ + id: schema.string(), + }), +]); + export const fieldObject = stringOptionalNullable; export const annotationsItems = schema.object({ @@ -47,7 +54,7 @@ export const annotationsItems = schema.object({ id: schema.string(), ignore_global_filters: numberIntegerOptional, ignore_panel_filters: numberIntegerOptional, - index_pattern: stringOptionalNullable, + index_pattern: indexPattern, query_string: schema.maybe(queryObject), template: stringOptionalNullable, time_field: fieldObject, @@ -68,6 +75,7 @@ const gaugeColorRulesItems = schema.object({ operator: stringOptionalNullable, value: schema.maybe(schema.nullable(schema.number())), }); + export const metricsItems = schema.object({ field: fieldObject, id: stringRequired, @@ -167,7 +175,7 @@ export const seriesItems = schema.object({ point_size: numberOptionalOrEmptyString, separate_axis: numberIntegerOptional, seperate_axis: numberIntegerOptional, - series_index_pattern: stringOptionalNullable, + series_index_pattern: indexPattern, series_max_bars: numberIntegerOptional, series_time_field: fieldObject, series_interval: stringOptionalNullable, @@ -195,6 +203,7 @@ export const seriesItems = schema.object({ }); export const panel = schema.object({ + use_kibana_indexes: schema.maybe(schema.boolean()), annotations: schema.maybe(schema.arrayOf(annotationsItems)), axis_formatter: stringRequired, axis_position: stringRequired, @@ -218,7 +227,7 @@ export const panel = schema.object({ id: stringRequired, ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, - index_pattern: stringRequired, + index_pattern: indexPattern, max_bars: numberIntegerOptional, interval: stringRequired, isModelInvalid: schema.maybe(schema.boolean()), diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx index 4fc7b89e23765..82989cc15d6c9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx @@ -10,8 +10,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiComboBox, EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui'; import { METRIC_TYPES } from '../../../../common/metric_types'; - -import type { SanitizedFieldType } from '../../../../common/types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; +import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types'; import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; // @ts-ignore @@ -20,7 +20,7 @@ import { isFieldEnabled } from '../../lib/check_ui_restrictions'; interface FieldSelectProps { type: string; fields: Record; - indexPattern: string; + indexPattern: IndexPatternValue; value?: string | null; onChange: (options: Array>) => void; disabled?: boolean; @@ -62,8 +62,10 @@ export function FieldSelect({ const selectedOptions: Array> = []; let newPlaceholder = placeholder; + const fieldsSelector = getIndexPatternKey(indexPattern); + const groupedOptions: EuiComboBoxProps['options'] = Object.values( - (fields[indexPattern] || []).reduce>>( + (fields[fieldsSelector] || []).reduce>>( (acc, field) => { if (placeholder === field?.name) { newPlaceholder = field.label ?? field.name; diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js index f95eeb4816128..ab0db6daae18a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js @@ -32,8 +32,8 @@ import { EuiCode, EuiText, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPatternSelect } from './lib/index_pattern_select'; function newAnnotation() { return { @@ -91,7 +91,6 @@ export class AnnotationsEditor extends Component { const htmlId = htmlIdGenerator(model.id); const handleAdd = collectionActions.handleAdd.bind(null, this.props, newAnnotation); const handleDelete = collectionActions.handleDelete.bind(null, this.props, model); - const defaultIndexPattern = this.props.model.default_index_pattern; return (
@@ -108,30 +107,11 @@ export class AnnotationsEditor extends Component { - - } - helpText={ - defaultIndexPattern && - !model.index_pattern && - i18n.translate('visTypeTimeseries.annotationsEditor.searchByDefaultIndex', { - defaultMessage: 'Default index pattern is used. To query all indexes use *', - }) - } - fullWidth - > - - + { const config = getUISettings(); const timeFieldName = `${prefix}time_field`; @@ -165,26 +167,13 @@ export const IndexPattern = ({ )} - - - + - {allowLevelofDetail && ( + {allowLevelOfDetail && ( >; + +/** @internal **/ +type SelectedOptions = EuiComboBoxProps['selectedOptions']; + +const toComboBoxOptions = (options: IdsWithTitle) => + options.map(({ title, id }) => ({ label: title, id })); + +export const ComboBoxSelect = ({ + fetchedIndex, + onIndexChange, + onModeChange, + disabled, + placeholder, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [availableIndexes, setAvailableIndexes] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + + const onComboBoxChange: EuiComboBoxProps['onChange'] = useCallback( + ([selected]) => { + onIndexChange(selected ? { id: selected.id } : ''); + }, + [onIndexChange] + ); + + useEffect(() => { + let options: SelectedOptions = []; + const { indexPattern, indexPatternString } = fetchedIndex; + + if (indexPattern || indexPatternString) { + if (!indexPattern) { + options = [{ label: indexPatternString ?? '' }]; + } else { + options = [ + { + id: indexPattern.id, + label: indexPattern.title, + }, + ]; + } + } + setSelectedOptions(options); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndexes() { + setAvailableIndexes(await getDataStart().indexPatterns.getIdsWithTitle()); + } + + fetchIndexes(); + }, []); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx new file mode 100644 index 0000000000000..86d1758932301 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useCallback, useState, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; +import { SwitchModePopover } from './switch_mode_popover'; + +import type { SelectIndexComponentProps } from './types'; + +export const FieldTextSelect = ({ + fetchedIndex, + onIndexChange, + disabled, + placeholder, + onModeChange, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [inputValue, setInputValue] = useState(); + const { indexPatternString } = fetchedIndex; + + const onFieldTextChange: EuiFieldTextProps['onChange'] = useCallback((e) => { + setInputValue(e.target.value); + }, []); + + useEffect(() => { + if (inputValue === undefined) { + setInputValue(indexPatternString ?? ''); + } + }, [indexPatternString, inputValue]); + + useDebounce( + () => { + if (inputValue !== indexPatternString) { + onIndexChange(inputValue); + } + }, + 150, + [inputValue, onIndexChange] + ); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts new file mode 100644 index 0000000000000..584f13e7a025b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { IndexPatternSelect } from './index_pattern_select'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx new file mode 100644 index 0000000000000..28b9c173a2b1b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useContext, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiFormRow, EuiText, EuiLink, htmlIdGenerator } from '@elastic/eui'; +import { getCoreStart, getDataStart } from '../../../../services'; +import { PanelModelContext } from '../../../contexts/panel_model_context'; + +import { + isStringTypeIndexPattern, + fetchIndexPattern, +} from '../../../../../common/index_patterns_utils'; + +import { FieldTextSelect } from './field_text_select'; +import { ComboBoxSelect } from './combo_box_select'; + +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; + +const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; + +interface IndexPatternSelectProps { + value: IndexPatternValue; + indexPatternName: string; + onChange: Function; + disabled?: boolean; + allowIndexSwitchingMode?: boolean; +} + +const defaultIndexPatternHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.defaultIndexPatternText', + { + defaultMessage: 'Default index pattern is used.', + } +); + +const queryAllIndexesHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.queryAllIndexesText', + { + defaultMessage: 'To query all indexes use *', + } +); + +const indexPatternLabel = i18n.translate('visTypeTimeseries.indexPatternSelect.label', { + defaultMessage: 'Index pattern', +}); + +export const IndexPatternSelect = ({ + value, + indexPatternName, + onChange, + disabled, + allowIndexSwitchingMode, +}: IndexPatternSelectProps) => { + const htmlId = htmlIdGenerator(); + const panelModel = useContext(PanelModelContext); + const [fetchedIndex, setFetchedIndex] = useState(); + const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); + const Component = useKibanaIndices ? ComboBoxSelect : FieldTextSelect; + + const onIndexChange = useCallback( + (index: IndexPatternValue) => { + onChange({ + [indexPatternName]: index, + }); + }, + [indexPatternName, onChange] + ); + + const onModeChange = useCallback( + (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => { + onChange({ + [USE_KIBANA_INDEXES_KEY]: useKibanaIndexes, + [indexPatternName]: index?.indexPattern?.id + ? { + id: index.indexPattern.id, + } + : '', + }); + }, + [onChange, indexPatternName] + ); + + const navigateToCreateIndexPatternPage = useCallback(() => { + const coreStart = getCoreStart(); + + coreStart.application.navigateToApp('management', { + path: `/kibana/indexPatterns/create?name=${fetchedIndex!.indexPatternString ?? ''}`, + }); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndex() { + const { indexPatterns } = getDataStart(); + + setFetchedIndex( + value + ? await fetchIndexPattern(value, indexPatterns) + : { + indexPattern: undefined, + indexPatternString: undefined, + } + ); + } + + fetchIndex(); + }, [value]); + + if (!fetchedIndex) { + return null; + } + + return ( + + + + + + ) : null + } + > + + + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx new file mode 100644 index 0000000000000..5f5506ce4a332 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonIcon, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; + +import type { PopoverProps } from './types'; + +export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onButtonClick = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []); + + const switchMode = useCallback(() => { + onModeChange(!useKibanaIndices); + }, [onModeChange, useKibanaIndices]); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + style={{ height: 'auto' }} + > +
+ + {i18n.translate('visTypeTimeseries.indexPatternSelect.switchModePopover.title', { + defaultMessage: 'Index pattern selection mode', + })} + + + + + + +
+
+ ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts new file mode 100644 index 0000000000000..93b15402e3c24 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Assign } from '@kbn/utility-types'; +import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; + +/** @internal **/ +export interface SelectIndexComponentProps { + fetchedIndex: FetchedIndexPattern; + onIndexChange: (value: IndexPatternValue) => void; + onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; + 'data-test-subj': string; + placeholder?: string; + disabled?: boolean; + allowSwitchMode?: boolean; +} + +/** @internal **/ +export type PopoverProps = Assign< + Pick, + { + useKibanaIndices: boolean; + } +>; diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx index e302bbb9adb0b..8a5077fca664c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx @@ -29,12 +29,11 @@ import type { Writable } from '@kbn/utility-types'; // @ts-ignore import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { createSelectHandler } from '../lib/create_select_handler'; import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { YesNo } from '../yes_no'; @@ -128,6 +127,7 @@ export class GaugePanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -149,10 +149,10 @@ export class GaugePanelConfig extends Component< language: model.filter?.language || getDefaultQueryLanguage(), query: model.filter?.query || '', }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx index c0f7e1b7b4743..a9d9d01376608 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx @@ -31,14 +31,13 @@ import type { Writable } from '@kbn/utility-types'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { createSelectHandler } from '../lib/create_select_handler'; import { ColorPicker } from '../color_picker'; import { YesNo } from '../yes_no'; // @ts-expect-error not typed yet import { MarkdownEditor } from '../markdown_editor'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { VisDataContext } from '../../contexts/vis_data_context'; @@ -143,6 +142,7 @@ export class MarkdownPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -161,13 +161,13 @@ export class MarkdownPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx index ec11f94d245a0..1cc0e48f135c8 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx @@ -25,12 +25,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { ColorRules } from '../color_rules'; import { YesNo } from '../yes_no'; - -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { limitOfSeries } from '../../../../common/ui_restrictions'; @@ -93,6 +91,7 @@ export class MetricPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -111,13 +110,13 @@ export class MetricPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/panel_config.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/panel_config.tsx index abe807f6180b6..17810ac362618 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/panel_config.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/panel_config.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { TimeseriesVisData } from '../../../../common/types'; import { FormValidationContext } from '../../contexts/form_validation_context'; import { VisDataContext } from '../../contexts/vis_data_context'; +import { PanelModelContext } from '../../contexts/panel_model_context'; import { PanelConfigProps } from './types'; import { TimeseriesPanelConfig as timeseries } from './timeseries'; import { MetricPanelConfig as metric } from './metric'; @@ -61,11 +62,13 @@ export function PanelConfig(props: PanelConfigProps) { if (Component) { return ( - -
- -
-
+ + +
+ +
+
+
); } diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 20e07be4e3fa4..01828eac74a0f 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -31,16 +31,17 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FieldSelect } from '../aggs/field_select'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { YesNo } from '../yes_no'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging + import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { VisDataContext } from '../../contexts/vis_data_context'; import { BUCKET_TYPES } from '../../../../common/metric_types'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export class TablePanelConfig extends Component< PanelConfigProps, @@ -66,7 +67,7 @@ export class TablePanelConfig extends Component< handlePivotChange = (selectedOption: Array>) => { const { fields, model } = this.props; const pivotId = get(selectedOption, '[0].value', null); - const field = fields[model.index_pattern].find((f) => f.name === pivotId); + const field = fields[getIndexPatternKey(model.index_pattern)].find((f) => f.name === pivotId); const pivotType = get(field, 'type', model.pivot_type); this.props.onChange({ @@ -237,15 +238,13 @@ export class TablePanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx index c211aafe57ac4..2e714b8db480b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx @@ -27,15 +27,14 @@ import { i18n } from '@kbn/i18n'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { AnnotationsEditor } from '../annotations_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { createSelectHandler } from '../lib/create_select_handler'; import { ColorPicker } from '../color_picker'; import { YesNo } from '../yes_no'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; @@ -183,9 +182,9 @@ export class TimeseriesPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} - allowLevelofDetail={true} + allowLevelOfDetail={true} + allowIndexSwitchingMode={true} /> - @@ -202,13 +201,13 @@ export class TimeseriesPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx index 184063f88ef03..6252c8f1c31bb 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx @@ -33,7 +33,6 @@ import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; import { YesNo } from '../yes_no'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; @@ -120,6 +119,7 @@ export class TopNPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -138,13 +138,13 @@ export class TopNPanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter: PanelConfigProps['model']['filter']) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx b/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx new file mode 100644 index 0000000000000..f9a5de313521a --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useContext, useEffect, useState } from 'react'; + +import { CoreStartContext } from '../contexts/query_input_bar_context'; +import { IndexPatternValue } from '../../../common/types'; + +import { QueryStringInput, QueryStringInputProps } from '../../../../../plugins/data/public'; +import { getDataStart } from '../../services'; +import { fetchIndexPattern, isStringTypeIndexPattern } from '../../../common/index_patterns_utils'; + +type QueryBarWrapperProps = Pick & { + indexPatterns: IndexPatternValue[]; +}; + +export function QueryBarWrapper({ query, onChange, indexPatterns }: QueryBarWrapperProps) { + const { indexPatterns: indexPatternsService } = getDataStart(); + const [indexes, setIndexes] = useState([]); + + const coreStartContext = useContext(CoreStartContext); + + useEffect(() => { + async function fetchIndexes() { + const i: QueryStringInputProps['indexPatterns'] = []; + + for (const index of indexPatterns ?? []) { + if (isStringTypeIndexPattern(index)) { + i.push(index); + } else if (index?.id) { + const fetchedIndex = await fetchIndexPattern(index, indexPatternsService); + + if (fetchedIndex.indexPattern) { + i.push(fetchedIndex.indexPattern); + } + } + } + setIndexes(i); + } + + fetchIndexes(); + }, [indexPatterns, indexPatternsService]); + + return ( + + ); +} diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 4e48ed4406ea5..3185503acb569 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -137,5 +137,5 @@ SeriesConfig.propTypes = { panel: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js index 0b67d52c23cd2..950101103b3a5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js @@ -90,5 +90,5 @@ SeriesConfigQueryBarWithIgnoreGlobalFilter.propTypes = { onChange: PropTypes.func, model: PropTypes.object, panel: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js index 5891320aa684f..b996abd6373ab 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js @@ -25,7 +25,7 @@ import { EuiFieldText, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { FIELD_TYPES } from '../../../../common/field_types'; +import { KBN_FIELD_TYPES } from '../../../../../data/public'; import { STACKED_OPTIONS } from '../../visualizations/constants'; const DEFAULTS = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' }; @@ -133,7 +133,7 @@ export const SplitByTermsUI = ({ - {selectedFieldType === FIELD_TYPES.STRING && ( + {selectedFieldType === KBN_FIELD_TYPES.STRING && ( { + abortableFetchFields = (extractedIndexPatterns: IndexPatternValue[]) => { this.abortControllerFetchFields?.abort(); this.abortControllerFetchFields = new AbortController(); @@ -202,7 +202,7 @@ export class VisEditor extends Component { const defaultIndexTitle = index?.title ?? ''; - const indexPatterns = extractIndexPatterns(this.props.vis.params, defaultIndexTitle); + const indexPatterns = extractIndexPatternValues(this.props.vis.params, defaultIndexTitle); const visFields = await fetchFields(indexPatterns); this.setState((state) => ({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js index 2909167031d08..46cc8b6ebe635 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js @@ -198,7 +198,7 @@ GaugeSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const GaugeSeries = injectI18n(GaugeSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js index 6f00abe5aa2c0..f9817242a101a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js @@ -200,7 +200,7 @@ MarkdownSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MarkdownSeries = injectI18n(MarkdownSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js index 64425cf534226..5ec2378792812 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js @@ -211,7 +211,7 @@ MetricSeriesUi.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MetricSeries = injectI18n(MetricSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js index fecd6cde1dca8..0ba8d3e855365 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js @@ -9,6 +9,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; + import { DataFormatPicker } from '../../data_format_picker'; import { createSelectHandler } from '../../lib/create_select_handler'; import { createTextHandler } from '../../lib/create_text_handler'; @@ -28,11 +30,11 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { getDefaultQueryLanguage } from '../../lib/get_default_query_language'; - import { QueryBarWrapper } from '../../query_bar_wrapper'; -class TableSeriesConfigUI extends Component { + +export class TableSeriesConfig extends Component { UNSAFE_componentWillMount() { const { model } = this.props; if (!model.color_rules || (model.color_rules && model.color_rules.length === 0)) { @@ -48,68 +50,58 @@ class TableSeriesConfigUI extends Component { const handleSelectChange = createSelectHandler(this.props.onChange); const handleTextChange = createTextHandler(this.props.onChange); const htmlId = htmlIdGenerator(); - const { intl } = this.props; const functionOptions = [ { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.sumLabel', + label: i18n.translate('visTypeTimeseries.table.sumLabel', { defaultMessage: 'Sum', }), value: 'sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.maxLabel', + label: i18n.translate('visTypeTimeseries.table.maxLabel', { defaultMessage: 'Max', }), value: 'max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.minLabel', + label: i18n.translate('visTypeTimeseries.table.minLabel', { defaultMessage: 'Min', }), value: 'min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.avgLabel', + label: i18n.translate('visTypeTimeseries.table.avgLabel', { defaultMessage: 'Avg', }), value: 'mean', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallSumLabel', + label: i18n.translate('visTypeTimeseries.table.overallSumLabel', { defaultMessage: 'Overall Sum', }), value: 'overall_sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMaxLabel', + label: i18n.translate('visTypeTimeseries.table.overallMaxLabel', { defaultMessage: 'Overall Max', }), value: 'overall_max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMinLabel', + label: i18n.translate('visTypeTimeseries.table.overallMinLabel', { defaultMessage: 'Overall Min', }), value: 'overall_min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallAvgLabel', + label: i18n.translate('visTypeTimeseries.table.overallAvgLabel', { defaultMessage: 'Overall Avg', }), value: 'overall_avg', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.cumulativeSumLabel', + label: i18n.translate('visTypeTimeseries.table.cumulativeSumLabel', { defaultMessage: 'Cumulative Sum', }), value: 'cumulative_sum', @@ -170,11 +162,8 @@ class TableSeriesConfigUI extends Component { > this.props.onChange({ filter })} indexPatterns={[this.props.indexPatternForQuery]} @@ -259,11 +248,9 @@ class TableSeriesConfigUI extends Component { } } -TableSeriesConfigUI.propTypes = { +TableSeriesConfig.propTypes = { fields: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; - -export const TableSeriesConfig = injectI18n(TableSeriesConfigUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js index a56afd1f817b3..acd2f4cc17d4a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js @@ -186,7 +186,7 @@ TableSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const TableSeries = injectI18n(TableSeriesUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 3df12dafd5a66..22bf2fa4ca708 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -542,7 +542,7 @@ export const TimeseriesConfig = injectI18n(function (props) { {...props} prefix="series_" disabled={!model.override_index_pattern} - allowLevelofDetail={true} + allowLevelOfDetail={true} /> @@ -555,6 +555,6 @@ TimeseriesConfig.propTypes = { model: PropTypes.object, panel: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js index 76df07ce7c8c4..bb10ac57c5ae9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js @@ -209,7 +209,7 @@ TimeseriesSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js index bfe446a8226e8..61bb7e2473dd9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js @@ -200,5 +200,5 @@ TopNSeries.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts new file mode 100644 index 0000000000000..534f686ca13fc --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.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. + */ + +import React from 'react'; +import { PanelSchema } from '../../../common/types'; + +export const PanelModelContext = React.createContext(null); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts index 088930f90a765..af3ddd643cac8 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts @@ -9,12 +9,14 @@ import { i18n } from '@kbn/i18n'; import { getCoreStart, getDataStart } from '../../services'; import { ROUTES } from '../../../common/constants'; -import { SanitizedFieldType } from '../../../common/types'; +import { SanitizedFieldType, IndexPatternValue } from '../../../common/types'; +import { getIndexPatternKey } from '../../../common/index_patterns_utils'; +import { toSanitizedFieldType } from '../../../common/fields_utils'; export type VisFields = Record; export async function fetchFields( - indexes: string[] = [], + indexes: IndexPatternValue[] = [], signal?: AbortSignal ): Promise { const patterns = Array.isArray(indexes) ? indexes : [indexes]; @@ -25,26 +27,33 @@ export async function fetchFields( const defaultIndexPattern = await dataStart.indexPatterns.getDefault(); const indexFields = await Promise.all( patterns.map(async (pattern) => { - return coreStart.http.get(ROUTES.FIELDS, { - query: { - index: pattern, - }, - signal, - }); + if (typeof pattern !== 'string' && pattern?.id) { + return toSanitizedFieldType( + (await dataStart.indexPatterns.get(pattern.id)).getNonScriptedFields() + ); + } else { + return coreStart.http.get(ROUTES.FIELDS, { + query: { + index: `${pattern ?? ''}`, + }, + signal, + }); + } }) ); const fields: VisFields = patterns.reduce( (cumulatedFields, currentPattern, index) => ({ ...cumulatedFields, - [currentPattern]: indexFields[index], + [getIndexPatternKey(currentPattern)]: indexFields[index], }), {} ); - if (defaultIndexPattern?.title && patterns.includes(defaultIndexPattern.title)) { - fields[''] = fields[defaultIndexPattern.title]; + if (defaultIndexPattern) { + fields[''] = toSanitizedFieldType(await defaultIndexPattern.getNonScriptedFields()); } + return fields; } catch (error) { if (error.name !== 'AbortError') { diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 9e996fcc74833..5d5e082b2b7bb 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { TSVB_EDITOR_NAME } from './application'; import { PANEL_TYPES } from '../common/panel_types'; +import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; import { toExpressionAst } from './to_ast'; import { VIS_EVENT_TO_TRIGGER, VisGroups, VisParams } from '../../visualizations/public'; import { getDataStart } from './services'; @@ -53,6 +54,7 @@ export const metricsVisDefinition = { ], time_field: '', index_pattern: '', + use_kibana_indexes: true, interval: '', axis_position: 'left', axis_formatter: 'number', @@ -77,7 +79,20 @@ export const metricsVisDefinition = { inspectorAdapters: {}, getUsedIndexPattern: async (params: VisParams) => { const { indexPatterns } = getDataStart(); + const indexPatternValue = params.index_pattern; - return params.index_pattern ? await indexPatterns.find(params.index_pattern) : []; + if (indexPatternValue) { + if (isStringTypeIndexPattern(indexPatternValue)) { + return await indexPatterns.find(indexPatternValue); + } + + if (indexPatternValue.id) { + return [await indexPatterns.get(indexPatternValue.id)]; + } + } + + const defaultIndex = await indexPatterns.getDefault(); + + return defaultIndex ? [defaultIndex] : []; }, }; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index f1bc5a11550e9..b0e85f8e44fbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -10,6 +10,7 @@ import { uniqBy } from 'lodash'; import { Framework } from '../plugin'; import { VisTypeTimeseriesFieldsRequest, VisTypeTimeseriesRequestHandlerContext } from '../types'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getFields( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -17,26 +18,29 @@ export async function getFields( framework: Framework, indexPatternString: string ) { + const indexPatternsService = await framework.getIndexPatternsService(requestContext); + const cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + if (!indexPatternString) { - const indexPatternsService = await framework.getIndexPatternsService(requestContext); const defaultIndexPattern = await indexPatternsService.getDefault(); indexPatternString = defaultIndexPattern?.title ?? ''; } + const fetchedIndex = await cachedIndexPatternFetcher(indexPatternString); + const { searchStrategy, capabilities, } = (await framework.searchStrategyRegistry.getViableStrategy( requestContext, request, - indexPatternString + fetchedIndex ))!; const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - request, - indexPatternString, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index 0ad50a296b481..d91104fb299d7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -19,6 +19,7 @@ import type { import { getSeriesData } from './vis_data/get_series_data'; import { getTableData } from './vis_data/get_table_data'; import { getEsQueryConfig } from './vis_data/helpers/get_es_query_uisettings'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getVisData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -29,12 +30,14 @@ export async function getVisData( const esShardTimeout = await framework.getEsShardTimeout(); const indexPatternsService = await framework.getIndexPatternsService(requestContext); const esQueryConfig = await getEsQueryConfig(uiSettings); + const services: VisTypeTimeseriesRequestServices = { esQueryConfig, esShardTimeout, indexPatternsService, uiSettings, searchStrategyRegistry: framework.searchStrategyRegistry, + cachedIndexPatternFetcher: getCachedIndexPatternFetcher(indexPatternsService), }; const promises = request.body.panels.map((panel) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts new file mode 100644 index 0000000000000..aeaf3ca2cd327 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { + getCachedIndexPatternFetcher, + CachedIndexPatternFetcher, +} from './cached_index_pattern_fetcher'; + +describe('CachedIndexPatternFetcher', () => { + let mockedIndices: IndexPattern[] | []; + let cachedIndexPatternFetcher: CachedIndexPatternFetcher; + + beforeEach(() => { + mockedIndices = []; + + const indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + }); + + test('should return default index on no input value', async () => { + const value = await cachedIndexPatternFetcher(''); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return default index if Kibana index not found', async () => { + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts new file mode 100644 index 0000000000000..68cbd93cdc614 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_patterns_utils'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; + +export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatternsService) => { + const cache = new Map(); + + return async (indexPatternValue: IndexPatternValue): Promise => { + const key = getIndexPatternKey(indexPatternValue); + + if (cache.has(key)) { + return cache.get(key); + } + + const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); + + cache.set(indexPatternValue, fetchedIndex); + + return fetchedIndex; + }; +}; + +export type CachedIndexPatternFetcher = ReturnType; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts similarity index 57% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index f95667612efa4..9003eb7fc2ced 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -6,21 +6,26 @@ * Side Public License, v 1. */ -import { - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../../types'; -import { AbstractSearchStrategy, DefaultSearchCapabilities } from '../../search_strategies'; +import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; +import type { AbstractSearchStrategy, DefaultSearchCapabilities } from '../index'; +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; export interface FieldsFetcherServices { - requestContext: VisTypeTimeseriesRequestHandlerContext; + indexPatternsService: IndexPatternsService; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; searchStrategy: AbstractSearchStrategy; capabilities: DefaultSearchCapabilities; } export const createFieldsFetcher = ( req: VisTypeTimeseriesVisDataRequest, - { capabilities, requestContext, searchStrategy }: FieldsFetcherServices + { + capabilities, + indexPatternsService, + searchStrategy, + cachedIndexPatternFetcher, + }: FieldsFetcherServices ) => { const fieldsCacheMap = new Map(); @@ -28,11 +33,11 @@ export const createFieldsFetcher = ( if (fieldsCacheMap.has(index)) { return fieldsCacheMap.get(index); } + const fetchedIndex = await cachedIndexPatternFetcher(index); const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - req, - index, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts deleted file mode 100644 index 512494de290fd..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { IndexPatternsService, IndexPattern } from '../../../../../data/server'; - -interface IndexPatternObjectDependencies { - indexPatternsService: IndexPatternsService; -} -export async function getIndexPatternObject( - indexPatternString: string, - { indexPatternsService }: IndexPatternObjectDependencies -) { - let indexPatternObject: IndexPattern | undefined | null; - - if (!indexPatternString) { - indexPatternObject = await indexPatternsService.getDefault(); - } else { - indexPatternObject = (await indexPatternsService.find(indexPatternString)).find( - (index) => index.title === indexPatternString - ); - } - - return { - indexPatternObject, - indexPatternString: indexPatternObject?.title || indexPatternString || '', - }; -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index f9a49bc322a29..a6e7c5b11ee64 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -10,29 +10,27 @@ import { get } from 'lodash'; import { SearchStrategyRegistry } from './search_strategy_registry'; import { AbstractSearchStrategy, DefaultSearchStrategy } from './strategies'; import { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; -import { Framework } from '../../plugin'; import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; const getPrivateField = (registry: SearchStrategyRegistry, field: string) => get(registry, field) as T; class MockSearchStrategy extends AbstractSearchStrategy { - checkForViability() { - return Promise.resolve({ + async checkForViability() { + return { isViable: true, capabilities: {}, - }); + }; } } describe('SearchStrategyRegister', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let registry: SearchStrategyRegistry; beforeAll(() => { registry = new SearchStrategyRegistry(); - registry.addStrategy(new DefaultSearchStrategy(framework)); + registry.addStrategy(new DefaultSearchStrategy()); }); test('should init strategies register', () => { @@ -47,12 +45,11 @@ describe('SearchStrategyRegister', () => { test('should return a DefaultSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof DefaultSearchStrategy).toBe(true); @@ -60,7 +57,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -69,14 +66,13 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof MockSearchStrategy).toBe(true); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts index 11ff4b0a8a51f..4a013fd89735d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts @@ -6,14 +6,10 @@ * Side Public License, v 1. */ -import { extractIndexPatterns } from '../../../common/extract_index_patterns'; -import { PanelSchema } from '../../../common/types'; -import { - VisTypeTimeseriesRequest, - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../types'; +import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; import { AbstractSearchStrategy } from './strategies'; +import { FetchedIndexPattern } from '../../../common/types'; + export class SearchStrategyRegistry { private strategies: AbstractSearchStrategy[] = []; @@ -27,13 +23,13 @@ export class SearchStrategyRegistry { async getViableStrategy( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ) { for (const searchStrategy of this.strategies) { const { isViable, capabilities } = await searchStrategy.checkForViability( requestContext, req, - indexPattern + fetchedIndexPattern ); if (isViable) { @@ -44,14 +40,4 @@ export class SearchStrategyRegistry { } } } - - async getViableStrategyForPanel( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesVisDataRequest, - panel: PanelSchema - ) { - const indexPattern = extractIndexPatterns(panel, panel.default_index_pattern).join(','); - - return this.getViableStrategy(requestContext, req, indexPattern); - } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index e7282eba58ec7..fb66e32447c22 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -6,48 +6,26 @@ * Side Public License, v 1. */ -const mockGetFieldsForWildcard = jest.fn(() => []); - -jest.mock('../../../../../data/server', () => ({ - indexPatterns: { - isNestedField: jest.fn(() => false), - }, - IndexPatternsFetcher: jest.fn().mockImplementation(() => ({ - getFieldsForWildcard: mockGetFieldsForWildcard, - })), -})); +import { IndexPatternsService } from '../../../../../data/common'; import { from } from 'rxjs'; -import { AbstractSearchStrategy, toSanitizedFieldType } from './abstract_search_strategy'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; import type { IFieldType } from '../../../../../data/common'; -import type { FieldSpec, RuntimeField } from '../../../../../data/common'; -import { - VisTypeTimeseriesRequest, +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { Framework } from '../../../plugin'; -import { indexPatterns } from '../../../../../data/server'; class FooSearchStrategy extends AbstractSearchStrategy {} describe('AbstractSearchStrategy', () => { let abstractSearchStrategy: AbstractSearchStrategy; let mockedFields: IFieldType[]; - let indexPattern: string; let requestContext: VisTypeTimeseriesRequestHandlerContext; - let framework: Framework; beforeEach(() => { mockedFields = []; - framework = ({ - getIndexPatternsService: jest.fn(() => - Promise.resolve({ - find: jest.fn(() => []), - getDefault: jest.fn(() => {}), - }) - ), - } as unknown) as Framework; requestContext = ({ core: { elasticsearch: { @@ -60,7 +38,7 @@ describe('AbstractSearchStrategy', () => { search: jest.fn().mockReturnValue(from(Promise.resolve({}))), }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - abstractSearchStrategy = new FooSearchStrategy(framework); + abstractSearchStrategy = new FooSearchStrategy(); }); test('should init an AbstractSearchStrategy instance', () => { @@ -71,17 +49,15 @@ describe('AbstractSearchStrategy', () => { test('should return fields for wildcard', async () => { const fields = await abstractSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesRequest, - indexPattern + { indexPatternString: '', indexPattern: undefined }, + ({ + getDefault: jest.fn(), + getFieldsForWildcard: jest.fn(() => Promise.resolve(mockedFields)), + } as unknown) as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher ); expect(fields).toEqual(mockedFields); - expect(mockGetFieldsForWildcard).toHaveBeenCalledWith({ - pattern: indexPattern, - metaFields: [], - fieldCapsOptions: { allow_no_indices: true }, - }); }); test('should return response', async () => { @@ -117,68 +93,4 @@ describe('AbstractSearchStrategy', () => { } ); }); - - describe('toSanitizedFieldType', () => { - const mockedField = { - lang: 'lang', - conflictDescriptions: {}, - aggregatable: true, - name: 'name', - type: 'type', - esTypes: ['long', 'geo'], - } as FieldSpec; - - test('should sanitize fields ', async () => { - const fields = [mockedField] as FieldSpec[]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` - Array [ - Object { - "label": "name", - "name": "name", - "type": "type", - }, - ] - `); - }); - - test('should filter runtime fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - runtimeField: {} as RuntimeField, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter non-aggregatable fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - aggregatable: false, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter nested fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - subType: { - nested: { - path: 'path', - }, - }, - }, - ]; - // @ts-expect-error - indexPatterns.isNestedField.mockReturnValue(true); - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 5bc008091627f..26c3a6c7c8bf7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -6,37 +6,17 @@ * Side Public License, v 1. */ -import { indexPatterns, IndexPatternsFetcher } from '../../../../../data/server'; +import { IndexPatternsService } from '../../../../../data/server'; +import { toSanitizedFieldType } from '../../../../common/fields_utils'; -import type { Framework } from '../../../plugin'; -import type { FieldSpec } from '../../../../../data/common'; -import type { SanitizedFieldType } from '../../../../common/types'; +import type { FetchedIndexPattern } from '../../../../common/types'; import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { getIndexPatternObject } from '../lib/get_index_pattern'; - -export const toSanitizedFieldType = (fields: FieldSpec[]) => { - return fields - .filter( - (field) => - // Make sure to only include mapped fields, e.g. no index pattern runtime fields - !field.runtimeField && field.aggregatable && !indexPatterns.isNestedField(field) - ) - .map( - (field) => - ({ - name: field.name, - label: field.customLabel ?? field.name, - type: field.type, - } as SanitizedFieldType) - ); -}; export abstract class AbstractSearchStrategy { - constructor(private framework: Framework) {} async search( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesVisDataRequest, @@ -66,35 +46,25 @@ export abstract class AbstractSearchStrategy { checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ): Promise<{ isViable: boolean; capabilities: any }> { throw new TypeError('Must override method'); } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown, options?: Partial<{ type: string; rollupIndex: string; }> ) { - const indexPatternsFetcher = new IndexPatternsFetcher( - requestContext.core.elasticsearch.client.asCurrentUser - ); - const indexPatternsService = await this.framework.getIndexPatternsService(requestContext); - const { indexPatternObject } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); - return toSanitizedFieldType( - indexPatternObject - ? indexPatternObject.getNonScriptedFields() - : await indexPatternsFetcher!.getFieldsForWildcard({ - pattern: indexPattern, - fieldCapsOptions: { allow_no_indices: true }, + fetchedIndexPattern.indexPattern + ? fetchedIndexPattern.indexPattern.getNonScriptedFields() + : await indexPatternsService.getFieldsForWildcard({ + pattern: fetchedIndexPattern.indexPatternString ?? '', metaFields: [], ...options, }) diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index b9824355374e1..d7a4e6ddedc89 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { Framework } from '../../../plugin'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, @@ -14,14 +13,13 @@ import { import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let defaultSearchStrategy: DefaultSearchStrategy; let req: VisTypeTimeseriesVisDataRequest; beforeEach(() => { req = {} as VisTypeTimeseriesVisDataRequest; - defaultSearchStrategy = new DefaultSearchStrategy(framework); + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index c925d8fcbb7c3..f95bf81b5c1d3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -8,25 +8,30 @@ import { AbstractSearchStrategy } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; -import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequest } from '../../../types'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestHandlerContext, + VisTypeTimeseriesRequest, +} from '../../../types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { - checkForViability( + async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest ) { - return Promise.resolve({ + return { isViable: true, capabilities: new DefaultSearchCapabilities(req), - }); + }; } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities); + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index 403013cfb9e10..c798f58b0b67b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -7,8 +7,10 @@ */ import { RollupSearchStrategy } from './rollup_search_strategy'; -import { Framework } from '../../../plugin'; -import { + +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; @@ -49,12 +51,11 @@ describe('Rollup Search Strategy', () => { }, }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - const framework = {} as Framework; const indexPattern = 'indexPattern'; test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(framework); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy).toBeDefined(); }); @@ -64,7 +65,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => Promise.resolve({ [rollupIndex]: { @@ -99,7 +100,7 @@ describe('Rollup Search Strategy', () => { const result = await rollupSearchStrategy.checkForViability( requestContext, {} as VisTypeTimeseriesVisDataRequest, - (null as unknown) as string + { indexPatternString: (null as unknown) as string, indexPattern: undefined } ); expect(result).toEqual({ @@ -113,7 +114,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy: RollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -140,7 +141,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { @@ -154,9 +155,9 @@ describe('Rollup Search Strategy', () => { test('should return fields for wildcard', async () => { const fields = await rollupSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesVisDataRequest, - indexPattern, + { indexPatternString: 'indexPattern', indexPattern: undefined }, + {} as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher, { fieldsCapabilities, rollupIndex, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 376d551624c8a..e6333ca420e0d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -6,19 +6,20 @@ * Side Public License, v 1. */ -import { getCapabilitiesForRollupIndices } from '../../../../../data/server'; -import { +import { getCapabilitiesForRollupIndices, IndexPatternsService } from '../../../../../data/server'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; + +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); -const isIndexPatternValid = (indexPattern: string) => - indexPattern && typeof indexPattern === 'string' && !isIndexPatternContainsWildcard(indexPattern); export class RollupSearchStrategy extends AbstractSearchStrategy { async search( @@ -33,24 +34,33 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { requestContext: VisTypeTimeseriesRequestHandlerContext, indexPattern: string ) { - return requestContext.core.elasticsearch.client.asCurrentUser.rollup - .getRollupIndexCaps({ + try { + const { + body, + } = await requestContext.core.elasticsearch.client.asCurrentUser.rollup.getRollupIndexCaps({ index: indexPattern, - }) - .then((data) => data.body) - .catch(() => Promise.resolve({})); + }); + + return body; + } catch (e) { + return {}; + } } async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + { indexPatternString, indexPattern }: FetchedIndexPattern ) { let isViable = false; let capabilities = null; - if (isIndexPatternValid(indexPattern)) { - const rollupData = await this.getRollupData(requestContext, indexPattern); + if ( + indexPatternString && + !isIndexPatternContainsWildcard(indexPatternString) && + (!indexPattern || indexPattern.type === 'rollup') + ) { + const rollupData = await this.getRollupData(requestContext, indexPatternString); const rollupIndices = getRollupIndices(rollupData); isViable = rollupIndices.length === 1; @@ -70,14 +80,14 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, + getCachedIndexPatternFetcher: CachedIndexPatternFetcher, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities, { + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities, { type: 'rollup', - rollupIndex: indexPattern, + rollupIndex: fetchedIndexPattern.indexPatternString, }); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts index c489a8d20b071..32086fbf4f5b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts @@ -8,7 +8,6 @@ import { AnnotationItemsSchema, PanelSchema } from 'src/plugins/vis_type_timeseries/common/types'; import { buildAnnotationRequest } from './build_request_body'; -import { getIndexPatternObject } from '../../search_strategies/lib/get_index_pattern'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequestServices, @@ -30,21 +29,20 @@ export async function getAnnotationRequestParams( esShardTimeout, esQueryConfig, capabilities, - indexPatternsService, uiSettings, + cachedIndexPatternFetcher, }: AnnotationServices ) { - const { - indexPatternObject, - indexPatternString, - } = await getIndexPatternObject(annotation.index_pattern!, { indexPatternsService }); + const { indexPattern, indexPatternString } = await cachedIndexPatternFetcher( + annotation.index_pattern + ); const request = await buildAnnotationRequest( req, panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index 9b371a8901e81..ebab984ff25aa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -10,8 +10,8 @@ import { AUTO_INTERVAL } from '../../../common/constants'; const DEFAULT_TIME_FIELD = '@timestamp'; -export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { - const getDefaultTimeField = () => indexPatternObject?.timeFieldName ?? DEFAULT_TIME_FIELD; +export function getIntervalAndTimefield(panel, series = {}, indexPattern) { + const getDefaultTimeField = () => indexPattern?.timeFieldName ?? DEFAULT_TIME_FIELD; const timeField = (series.override_index_pattern && series.series_time_field) || diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts index f521de632b1f8..13dc1207f51de 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts @@ -21,6 +21,7 @@ import type { VisTypeTimeseriesRequestServices, } from '../../types'; import type { PanelSchema } from '../../../common/types'; +import { PANEL_TYPES } from '../../../common/panel_types'; export async function getSeriesData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -28,10 +29,12 @@ export async function getSeriesData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const strategy = await services.searchStrategyRegistry.getViableStrategyForPanel( + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); + + const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panel + panelIndex ); if (!strategy) { @@ -50,14 +53,15 @@ export async function getSeriesData( try { const bodiesPromises = getActiveSeries(panel).map((series) => - getSeriesRequestParams(req, panel, series, capabilities, services) + getSeriesRequestParams(req, panel, panelIndex, series, capabilities, services) ); const searches = await Promise.all(bodiesPromises); const data = await searchStrategy.search(requestContext, req, searches); const handleResponseBodyFn = handleResponseBody(panel, req, { - requestContext, + indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, searchStrategy, capabilities, }); @@ -70,7 +74,7 @@ export async function getSeriesData( let annotations = null; - if (panel.annotations && panel.annotations.length) { + if (panel.type === PANEL_TYPES.TIMESERIES && panel.annotations && panel.annotations.length) { annotations = await getAnnotations({ req, panel, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts index a35a3246b0dd3..0cc1188086b7b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts @@ -16,8 +16,8 @@ import { buildRequestBody } from './table/build_request_body'; import { handleErrorResponse } from './handle_error_response'; // @ts-expect-error import { processBucket } from './table/process_bucket'; -import { getIndexPatternObject } from '../search_strategies/lib/get_index_pattern'; -import { createFieldsFetcher } from './helpers/fields_fetcher'; + +import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher'; import { extractFieldLabel } from '../../../common/calculate_label'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -32,12 +32,12 @@ export async function getTableData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const panelIndexPattern = panel.index_pattern; + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panelIndexPattern + panelIndex ); if (!strategy) { @@ -49,15 +49,17 @@ export async function getTableData( } const { searchStrategy, capabilities } = strategy; - const { indexPatternObject } = await getIndexPatternObject(panelIndexPattern, { + + const extractFields = createFieldsFetcher(req, { indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, + searchStrategy, + capabilities, }); - const extractFields = createFieldsFetcher(req, { requestContext, searchStrategy, capabilities }); - const calculatePivotLabel = async () => { - if (panel.pivot_id && indexPatternObject?.title) { - const fields = await extractFields(indexPatternObject.title); + if (panel.pivot_id && panelIndex.indexPattern?.title) { + const fields = await extractFields(panelIndex.indexPattern.title); return extractFieldLabel(fields, panel.pivot_id); } @@ -75,7 +77,7 @@ export async function getTableData( req, panel, services.esQueryConfig, - indexPatternObject, + panelIndex.indexPattern, capabilities, services.uiSettings ); @@ -83,7 +85,7 @@ export async function getTableData( const [resp] = await searchStrategy.search(requestContext, req, [ { body, - index: panelIndexPattern, + index: panelIndex.indexPatternString, }, ]); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 0d100f6310b99..48b33c1e787e9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -18,7 +18,7 @@ export function dateHistogram( panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index 9ff0325b60e82..dab9a24d06c0f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -19,7 +19,7 @@ export function dateHistogram( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { @@ -27,11 +27,7 @@ export function dateHistogram( const maxBarsUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval, maxBars } = getIntervalAndTimefield( - panel, - series, - indexPatternObject - ); + const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize, intervalString } = getBucketSize( req, interval, @@ -68,7 +64,7 @@ export function dateHistogram( overwrite(doc, `aggs.${series.id}.meta`, { timeField, intervalString, - index: indexPatternObject?.title, + index: indexPattern?.title, bucketSize, seriesId: series.id, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index d653f6acf6f3e..945c57b2341f3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -16,7 +16,7 @@ describe('dateHistogram(req, panel, series)', () => { let req; let capabilities; let config; - let indexPatternObject; + let indexPattern; let uiSettings; beforeEach(() => { @@ -39,7 +39,7 @@ describe('dateHistogram(req, panel, series)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - indexPatternObject = {}; + indexPattern = {}; capabilities = new DefaultSearchCapabilities(req); uiSettings = { get: async (key) => (key === UI_SETTINGS.HISTOGRAM_MAX_BARS ? 100 : 50), @@ -49,15 +49,9 @@ describe('dateHistogram(req, panel, series)', () => { test('calls next when finished', async () => { const next = jest.fn(); - await dateHistogram( - req, - panel, - series, - config, - indexPatternObject, - capabilities, - uiSettings - )(next)({}); + await dateHistogram(req, panel, series, config, indexPattern, capabilities, uiSettings)(next)( + {} + ); expect(next.mock.calls.length).toEqual(1); }); @@ -69,7 +63,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -110,7 +104,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -154,7 +148,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -198,7 +192,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -216,7 +210,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js index 31ae988718a27..4639af9db83b8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -12,19 +12,19 @@ import { esQuery } from '../../../../../../data/server'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, series, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { if (series.metrics.some(filter)) { series.metrics.filter(filter).forEach((metric) => { overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js index 9e0dd4f76c13f..345488ec01d5e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js @@ -13,7 +13,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => let series; let req; let esQueryConfig; - let indexPatternObject; + let indexPattern; beforeEach(() => { panel = { time_field: 'timestamp', @@ -47,18 +47,18 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => queryStringOptions: { analyze_wildcard: true }, ignoreFilterIfFieldNotInIndex: false, }; - indexPatternObject = {}; + indexPattern = {}; }); test('calls next when finished', () => { const next = jest.fn(); - ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns filter ratio aggs', () => { const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -135,7 +135,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => test('returns empty object when field is not set', () => { delete series.metrics[0].field; const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 649b3cee6ea3e..86b691f6496c9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -17,14 +17,14 @@ export function metricBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 1d67df7c92eb6..ce61374c0b124 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -56,14 +56,14 @@ export function positiveRate( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); if (series.metrics.some(filter)) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js index cb12aa3513b91..d0e92c9157cb5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js @@ -10,16 +10,16 @@ import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, series, esQueryConfig, indexPatternObject) { +export function query(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, series, indexPattern); const { from, to } = offsetTime(req, series.offset_time); doc.size = 0; const ignoreGlobalFilter = panel.ignore_global_filter || series.ignore_global_filter; const queries = !ignoreGlobalFilter ? req.body.query : []; const filters = !ignoreGlobalFilter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -34,13 +34,13 @@ export function query(req, panel, series, esQueryConfig, indexPatternObject) { if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } if (series.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [series.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index 315ccdfc13a47..401344d48f865 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -17,13 +17,13 @@ export function siblingBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 0ae6d113e28e4..5518065643172 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -15,20 +15,13 @@ import { calculateAggRoot } from './calculate_agg_root'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function dateHistogram(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPattern); const meta = { timeField, - index: indexPatternObject?.title, + index: indexPattern?.title, }; const getDateHistogramForLastBucketMode = () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js index 7b3ac16cd6561..abb5971908771 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js @@ -13,7 +13,7 @@ import { calculateAggRoot } from './calculate_agg_root'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -22,12 +22,12 @@ export function ratios(req, panel, esQueryConfig, indexPatternObject) { overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 53149a31603ef..5ce508bd9b279 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index 8c7a0f5e2367f..176721e7b563a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -12,17 +12,10 @@ import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function positiveRate( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function positiveRate(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js index a0118c5037d34..76df07b76e80e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js @@ -10,16 +10,16 @@ import { getTimerange } from '../../helpers/get_timerange'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, esQueryConfig, indexPatternObject) { +export function query(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, {}, indexPattern); const { from, to } = getTimerange(req); doc.size = 0; const queries = !panel.ignore_global_filter ? req.body.query : []; const filters = !panel.ignore_global_filter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -33,7 +33,7 @@ export function query(req, panel, esQueryConfig, indexPatternObject) { doc.query.bool.must.push(timerange); if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index d205f0679a908..5539f16df41e0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 968fe01565b04..d97af8ac748f4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -79,13 +79,13 @@ describe('buildRequestBody(req)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - const indexPatternObject = {}; + const indexPattern = {}; const doc = await buildRequestBody( { body }, panel, series, config, - indexPatternObject, + indexPattern, capabilities, { get: async () => 50, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts index ae846b5b4b817..1f2735da8fb06 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts @@ -6,43 +6,46 @@ * Side Public License, v 1. */ -import { PanelSchema, SeriesItemsSchema } from '../../../../common/types'; import { buildRequestBody } from './build_request_body'; -import { getIndexPatternObject } from '../../../lib/search_strategies/lib/get_index_pattern'; -import { VisTypeTimeseriesRequestServices, VisTypeTimeseriesVisDataRequest } from '../../../types'; -import { DefaultSearchCapabilities } from '../../search_strategies'; + +import type { FetchedIndexPattern, PanelSchema, SeriesItemsSchema } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestServices, + VisTypeTimeseriesVisDataRequest, +} from '../../../types'; +import type { DefaultSearchCapabilities } from '../../search_strategies'; export async function getSeriesRequestParams( req: VisTypeTimeseriesVisDataRequest, panel: PanelSchema, + panelIndex: FetchedIndexPattern, series: SeriesItemsSchema, capabilities: DefaultSearchCapabilities, { esQueryConfig, esShardTimeout, uiSettings, - indexPatternsService, + cachedIndexPatternFetcher, }: VisTypeTimeseriesRequestServices ) { - const indexPattern = - (series.override_index_pattern && series.series_index_pattern) || panel.index_pattern; + let seriesIndex = panelIndex; - const { indexPatternObject, indexPatternString } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); + if (series.override_index_pattern) { + seriesIndex = await cachedIndexPatternFetcher(series.series_index_pattern ?? ''); + } const request = await buildRequestBody( req, panel, series, esQueryConfig, - indexPatternObject, + seriesIndex.indexPattern, capabilities, uiSettings ); return { - index: indexPatternString, + index: seriesIndex.indexPatternString, body: { ...request, timeout: esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts index 22e0372c23526..49f1ec0f93de5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts @@ -12,7 +12,10 @@ import { PanelSchema } from '../../../../common/types'; import { buildProcessorFunction } from '../build_processor_function'; // @ts-expect-error import { processors } from '../response_processors/series'; -import { createFieldsFetcher, FieldsFetcherServices } from './../helpers/fields_fetcher'; +import { + createFieldsFetcher, + FieldsFetcherServices, +} from '../../search_strategies/lib/fields_fetcher'; import { VisTypeTimeseriesVisDataRequest } from '../../../types'; export function handleResponseBody( diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 71b76dddbca6a..95fdc59ceb232 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -111,8 +111,8 @@ export class VisTypeTimeseriesPlugin implements Plugin { }, }; - searchStrategyRegistry.addStrategy(new DefaultSearchStrategy(framework)); - searchStrategyRegistry.addStrategy(new RollupSearchStrategy(framework)); + searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); + searchStrategyRegistry.addStrategy(new RollupSearchStrategy()); visDataRoutes(router, framework); fieldsRoutes(router, framework); diff --git a/src/plugins/vis_type_timeseries/server/types.ts b/src/plugins/vis_type_timeseries/server/types.ts index da32669b3855d..3ab5d8ac6ea7a 100644 --- a/src/plugins/vis_type_timeseries/server/types.ts +++ b/src/plugins/vis_type_timeseries/server/types.ts @@ -12,8 +12,9 @@ import type { EsQueryConfig, IndexPatternsService, } from '../../data/server'; -import { VisPayload } from '../common/types'; -import { SearchStrategyRegistry } from './lib/search_strategies'; +import type { VisPayload } from '../common/types'; +import type { SearchStrategyRegistry } from './lib/search_strategies'; +import type { CachedIndexPatternFetcher } from './lib/search_strategies/lib/cached_index_pattern_fetcher'; export type VisTypeTimeseriesRequestHandlerContext = DataRequestHandlerContext; export type VisTypeTimeseriesRouter = IRouter; @@ -29,4 +30,5 @@ export interface VisTypeTimeseriesRequestServices { uiSettings: IUiSettingsClient; indexPatternsService: IndexPatternsService; searchStrategyRegistry: SearchStrategyRegistry; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; } diff --git a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx index efb41c470024b..f5b0f614458fd 100644 --- a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx @@ -11,6 +11,8 @@ import { EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } fr import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { getDocLinks } from '../services'; + function VegaHelpMenu() { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const onButtonClick = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); @@ -30,7 +32,7 @@ function VegaHelpMenu() { const items = [ diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts index 042ffac583e98..8590b51d3b5ff 100644 --- a/src/plugins/vis_type_vega/public/data_model/types.ts +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SearchResponse, SearchParams } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { Filter } from 'src/plugins/data/public'; import { DslQuery } from 'src/plugins/data/common'; @@ -17,7 +17,7 @@ import { EmsFileParser } from './ems_file_parser'; import { UrlParser } from './url_parser'; interface Body { - aggs?: SearchParams['body']['aggs']; + aggs?: Record; query?: Query; timeout?: string; } @@ -76,7 +76,7 @@ interface Projection { interface RequestDataObject { name?: string; url?: TUrlData; - values: SearchResponse; + values: estypes.SearchResponse; } type ContextVarsObjectProps = diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 0204c2c90b71b..f935362d21604 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -19,6 +19,7 @@ import { setUISettings, setInjectedMetadata, setMapServiceSettings, + setDocLinks, } from './services'; import { createVegaFn } from './vega_fn'; @@ -96,5 +97,6 @@ export class VegaPlugin implements Plugin { setNotifications(core.notifications); setData(data); setInjectedMetadata(core.injectedMetadata); + setDocLinks(core.docLinks); } } diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index c47378282932b..f67fe4794e783 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/public'; +import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from 'src/core/public'; import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; @@ -35,3 +35,5 @@ export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ }>('InjectedVars'); export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; + +export const [getDocLinks, setDocLinks] = createGetterSetter('docLinks'); diff --git a/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts b/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts index 8d3512aa2138e..d5f8d978d5252 100644 --- a/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts +++ b/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts @@ -7,14 +7,12 @@ */ import { parse } from 'hjson'; -import { SearchResponse } from 'elasticsearch'; import { ElasticsearchClient, SavedObject } from 'src/core/server'; import { VegaSavedObjectAttributes, VisTypeVegaPluginSetupDependencies } from '../types'; type UsageCollectorDependencies = Pick; -type ESResponse = SearchResponse<{ visualization: { visState: string } }>; type VegaType = 'vega' | 'vega-lite'; function isVegaType(attributes: any): attributes is VegaSavedObjectAttributes { @@ -80,7 +78,9 @@ export const getStats = async ( }, }; - const { body: esResponse } = await esClient.search(searchParams); + const { body: esResponse } = await esClient.search<{ visualization: { visState: string } }>( + searchParams + ); const size = esResponse?.hits?.hits?.length ?? 0; if (!size) { diff --git a/src/plugins/vis_type_vislib/public/gauge.ts b/src/plugins/vis_type_vislib/public/gauge.ts index 172ce83b4f7c2..7e3ff8226fbb6 100644 --- a/src/plugins/vis_type_vislib/public/gauge.ts +++ b/src/plugins/vis_type_vislib/public/gauge.ts @@ -119,6 +119,7 @@ export const gaugeVisTypeDefinition: VisTypeDefinition = { '!moving_avg', '!cumulative_sum', '!geo_bounds', + '!filtered_metric', ], defaults: [{ schema: 'metric', type: 'count' }], }, diff --git a/src/plugins/vis_type_vislib/public/goal.ts b/src/plugins/vis_type_vislib/public/goal.ts index aaeae4f675f3f..468651bb4cf4c 100644 --- a/src/plugins/vis_type_vislib/public/goal.ts +++ b/src/plugins/vis_type_vislib/public/goal.ts @@ -83,6 +83,7 @@ export const goalVisTypeDefinition: VisTypeDefinition = { '!moving_avg', '!cumulative_sum', '!geo_bounds', + '!filtered_metric', ], defaults: [{ schema: 'metric', type: 'count' }], }, diff --git a/src/plugins/vis_type_vislib/public/heatmap.ts b/src/plugins/vis_type_vislib/public/heatmap.ts index 6f6160f3756fd..8d538399f68b2 100644 --- a/src/plugins/vis_type_vislib/public/heatmap.ts +++ b/src/plugins/vis_type_vislib/public/heatmap.ts @@ -94,6 +94,7 @@ export const heatmapVisTypeDefinition: VisTypeDefinition = { 'cardinality', 'std_dev', 'top_hits', + '!filtered_metric', ], defaults: [{ schema: 'metric', type: 'count' }], }, diff --git a/src/plugins/vis_type_xy/public/vis_types/area.ts b/src/plugins/vis_type_xy/public/vis_types/area.ts index a61c25bbc075a..dfe9bc2f42b84 100644 --- a/src/plugins/vis_type_xy/public/vis_types/area.ts +++ b/src/plugins/vis_type_xy/public/vis_types/area.ts @@ -133,7 +133,7 @@ export const getAreaVisTypeDefinition = ( title: i18n.translate('visTypeXy.area.metricsTitle', { defaultMessage: 'Y-axis', }), - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], min: 1, defaults: [{ schema: 'metric', type: 'count' }], }, diff --git a/src/plugins/vis_type_xy/public/vis_types/histogram.ts b/src/plugins/vis_type_xy/public/vis_types/histogram.ts index 2c2a83b48802d..ba20502a3b9af 100644 --- a/src/plugins/vis_type_xy/public/vis_types/histogram.ts +++ b/src/plugins/vis_type_xy/public/vis_types/histogram.ts @@ -137,7 +137,7 @@ export const getHistogramVisTypeDefinition = ( defaultMessage: 'Y-axis', }), min: 1, - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], defaults: [{ schema: 'metric', type: 'count' }], }, { diff --git a/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts b/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts index 75c4ddd75d0b3..62da0448e56bd 100644 --- a/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts +++ b/src/plugins/vis_type_xy/public/vis_types/horizontal_bar.ts @@ -136,7 +136,7 @@ export const getHorizontalBarVisTypeDefinition = ( defaultMessage: 'Y-axis', }), min: 1, - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], defaults: [{ schema: 'metric', type: 'count' }], }, { diff --git a/src/plugins/vis_type_xy/public/vis_types/line.ts b/src/plugins/vis_type_xy/public/vis_types/line.ts index 87165a20592e5..5a9eb5198df35 100644 --- a/src/plugins/vis_type_xy/public/vis_types/line.ts +++ b/src/plugins/vis_type_xy/public/vis_types/line.ts @@ -132,7 +132,7 @@ export const getLineVisTypeDefinition = ( name: 'metric', title: i18n.translate('visTypeXy.line.metricTitle', { defaultMessage: 'Y-axis' }), min: 1, - aggFilter: ['!geo_centroid', '!geo_bounds'], + aggFilter: ['!geo_centroid', '!geo_bounds', '!filtered_metric'], defaults: [{ schema: 'metric', type: 'count' }], }, { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 349e024f31c31..c2b9fcd77757a 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -12,6 +12,8 @@ import { first } from 'rxjs/operators'; import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { SavedObjectAttributes } from '../../../../core/public'; import { extractSearchSourceReferences } from '../../../data/public'; +import { SavedObjectReference } from '../../../../core/public'; + import { EmbeddableFactoryDefinition, EmbeddableOutput, @@ -38,6 +40,12 @@ import { } from '../services'; import { showNewVisModal } from '../wizard'; import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; +import { + extractControlsReferences, + extractTimeSeriesReferences, + injectTimeSeriesReferences, + injectControlsReferences, +} from '../saved_visualizations/saved_visualization_references'; import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object'; import { StartServicesGetter } from '../../../kibana_utils/public'; import { VisualizationsStartDeps } from '../plugin'; @@ -239,6 +247,19 @@ export class VisualizeEmbeddableFactory ); } + public inject(_state: EmbeddableStateWithType, references: SavedObjectReference[]) { + const state = (_state as unknown) as VisualizeInput; + + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); + } + + return _state; + } + public extract(_state: EmbeddableStateWithType) { const state = (_state as unknown) as VisualizeInput; const references = []; @@ -259,19 +280,11 @@ export class VisualizeEmbeddableFactory }); } - if (state.savedVis?.params.controls) { - const controls = state.savedVis.params.controls; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - references.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - }); + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + extractControlsReferences(type, params, references, `control_${state.id}`); + extractTimeSeriesReferences(type, params, references, `metrics_${state.id}`); } return { state: _state, references }; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts new file mode 100644 index 0000000000000..d116fd2e2e9a7 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +const isControlsVis = (visType: string) => visType === 'input_control_vis'; + +export const extractControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'control' +) => { + if (isControlsVis(visType)) { + (visParams?.controls ?? []).forEach((control: Record, i: number) => { + if (!control.indexPattern) { + return; + } + control.indexPatternRefName = `${prefix}_${i}_index_pattern`; + references.push({ + name: control.indexPatternRefName, + type: 'index-pattern', + id: control.indexPattern, + }); + delete control.indexPattern; + }); + } +}; + +export const injectControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isControlsVis(visType)) { + (visParams.controls ?? []).forEach((control: Record) => { + if (!control.indexPatternRefName) { + return; + } + const reference = references.find((ref) => ref.name === control.indexPatternRefName); + if (!reference) { + throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); + } + control.indexPattern = reference.id; + delete control.indexPatternRefName; + }); + } +}; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts new file mode 100644 index 0000000000000..0acda1c0a0f80 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may 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 { extractControlsReferences, injectControlsReferences } from './controls_references'; +export { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; + +export { extractReferences, injectReferences } from './saved_visualization_references'; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts similarity index 69% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts index f81054febcc44..867febd2544b0 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts @@ -7,8 +7,8 @@ */ import { extractReferences, injectReferences } from './saved_visualization_references'; -import { VisSavedObject } from '../types'; -import { SavedVisState } from '../../common'; +import { VisSavedObject } from '../../types'; +import { SavedVisState } from '../../../common'; describe('extractReferences', () => { test('extracts nothing if savedSearchId is empty', () => { @@ -21,13 +21,13 @@ describe('extractReferences', () => { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - }, - "references": Array [], -} -`); + Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], + } + `); }); test('extracts references from savedSearchId', () => { @@ -41,20 +41,20 @@ Object { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "savedSearchRefName": "search_0", - }, - "references": Array [ - Object { - "id": "123", - "name": "search_0", - "type": "search", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "savedSearchRefName": "search_0", + }, + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + } + `); }); test('extracts references from controls', () => { @@ -63,6 +63,7 @@ Object { attributes: { foo: true, visState: JSON.stringify({ + type: 'input_control_vis', params: { controls: [ { @@ -81,20 +82,20 @@ Object { const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "visState": "{\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", - }, - "references": Array [ - Object { - "id": "pattern*", - "name": "control_0_index_pattern", - "type": "index-pattern", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "visState": "{\\"type\\":\\"input_control_vis\\",\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", + }, + "references": Array [ + Object { + "id": "pattern*", + "name": "control_0_index_pattern", + "type": "index-pattern", + }, + ], + } + `); }); }); @@ -106,11 +107,11 @@ describe('injectReferences', () => { } as VisSavedObject; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "title": "test", -} -`); + Object { + "id": "1", + "title": "test", + } + `); }); test('injects references into context', () => { @@ -119,6 +120,7 @@ Object { title: 'test', savedSearchRefName: 'search_0', visState: ({ + type: 'input_control_vis', params: { controls: [ { @@ -146,25 +148,26 @@ Object { ]; injectReferences(context, references); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "savedSearchId": "123", - "title": "test", - "visState": Object { - "params": Object { - "controls": Array [ - Object { - "foo": true, - "indexPattern": "pattern*", - }, - Object { - "foo": false, + Object { + "id": "1", + "savedSearchId": "123", + "title": "test", + "visState": Object { + "params": Object { + "controls": Array [ + Object { + "foo": true, + "indexPattern": "pattern*", + }, + Object { + "foo": false, + }, + ], + }, + "type": "input_control_vis", }, - ], - }, - }, -} -`); + } + `); }); test(`fails when it can't find the saved search reference in the array`, () => { @@ -183,6 +186,7 @@ Object { id: '1', title: 'test', visState: ({ + type: 'input_control_vis', params: { controls: [ { diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts similarity index 67% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts index 27b5a4542036b..6a4f9812db971 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts @@ -10,13 +10,16 @@ import { SavedObjectAttribute, SavedObjectAttributes, SavedObjectReference, -} from '../../../../core/public'; -import { VisSavedObject } from '../types'; +} from '../../../../../core/public'; +import { SavedVisState, VisSavedObject } from '../../types'; import { extractSearchSourceReferences, injectSearchSourceReferences, SearchSourceFields, -} from '../../../data/public'; +} from '../../../../data/public'; + +import { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; +import { extractControlsReferences, injectControlsReferences } from './controls_references'; export function extractReferences({ attributes, @@ -49,20 +52,13 @@ export function extractReferences({ // Extract index patterns from controls if (updatedAttributes.visState) { - const visState = JSON.parse(String(updatedAttributes.visState)); - const controls = (visState.params && visState.params.controls) || []; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - updatedReferences.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - delete control.indexPattern; - }); + const visState = JSON.parse(String(updatedAttributes.visState)) as SavedVisState; + + if (visState.type && visState.params) { + extractControlsReferences(visState.type, visState.params, updatedReferences); + extractTimeSeriesReferences(visState.type, visState.params, updatedReferences); + } + updatedAttributes.visState = JSON.stringify(visState); } @@ -89,18 +85,11 @@ export function injectReferences(savedObject: VisSavedObject, references: SavedO savedObject.savedSearchId = savedSearchReference.id; delete savedObject.savedSearchRefName; } - if (savedObject.visState) { - const controls = (savedObject.visState.params && savedObject.visState.params.controls) || []; - controls.forEach((control: Record) => { - if (!control.indexPatternRefName) { - return; - } - const reference = references.find((ref) => ref.name === control.indexPatternRefName); - if (!reference) { - throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); - } - control.indexPattern = reference.id; - delete control.indexPatternRefName; - }); + + const { type, params } = savedObject.visState ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); } } diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts new file mode 100644 index 0000000000000..57706ee824e8d --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +/** @internal **/ +const REF_NAME_POSTFIX = '_ref_name'; + +/** @internal **/ +const INDEX_PATTERN_REF_TYPE = 'index_pattern'; + +/** @internal **/ +type Action = (object: Record, key: string) => void; + +const isMetricsVis = (visType: string) => visType === 'metrics'; + +const doForExtractedIndices = (action: Action, visParams: VisParams) => { + action(visParams, 'index_pattern'); + + visParams.series.forEach((series: any) => { + if (series.override_index_pattern) { + action(series, 'series_index_pattern'); + } + }); + + if (visParams.annotations) { + visParams.annotations.forEach((annotation: any) => { + action(annotation, 'index_pattern'); + }); + } +}; + +export const extractTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'metrics' +) => { + let i = 0; + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + if (object[key] && object[key].id) { + const name = `${prefix}_${i++}_index_pattern`; + + object[key + REF_NAME_POSTFIX] = name; + references.push({ + name, + type: INDEX_PATTERN_REF_TYPE, + id: object[key].id, + }); + delete object[key]; + } + }, visParams); + } +}; + +export const injectTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + const refKey = key + REF_NAME_POSTFIX; + + if (object[refKey]) { + const refValue = references.find((ref) => ref.name === object[refKey]); + + if (refValue) { + object[key] = { id: refValue.id }; + } + + delete object[refKey]; + } + }, visParams); + } +}; diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts index afb59266d0dbf..ced33318413c5 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts @@ -790,6 +790,35 @@ const removeTSVBSearchSource: SavedObjectMigrationFn = (doc) => { return doc; }; +const addSupportOfDualIndexSelectionModeInTSVB: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + if (visState && visState.type === 'metrics') { + const { params } = visState; + + if (typeof params?.index_pattern === 'string') { + params.use_kibana_indexes = false; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(visState), + }, + }; + } + } + return doc; +}; + // [Data table visualization] Enable toolbar by default const enableDataTableVisToolbar: SavedObjectMigrationFn = (doc) => { let visState; @@ -929,4 +958,5 @@ export const visualizationSavedObjectTypeMigrations = { '7.10.0': flow(migrateFilterRatioQuery, removeTSVBSearchSource), '7.11.0': flow(enableDataTableVisToolbar), '7.12.0': flow(migrateVislibAreaLineBarTypes, migrateSchema), + '7.13.0': flow(addSupportOfDualIndexSelectionModeInTSVB), }; diff --git a/src/plugins/visualizations/server/usage_collector/get_usage_collector.ts b/src/plugins/visualizations/server/usage_collector/get_usage_collector.ts index 164d5b5fa72ac..89e1e7f03e149 100644 --- a/src/plugins/visualizations/server/usage_collector/get_usage_collector.ts +++ b/src/plugins/visualizations/server/usage_collector/get_usage_collector.ts @@ -61,7 +61,7 @@ export async function getStats( // `map` to get the raw types const visSummaries: VisSummary[] = esResponse.hits.hits.map((hit) => { - const spacePhrases = hit._id.split(':'); + const spacePhrases = hit._id.toString().split(':'); const lastUpdated: string = get(hit, '_source.updated_at'); const space = spacePhrases.length === 3 ? spacePhrases[0] : 'default'; // if in a custom space, the format of a saved object ID is space:type:id const visualization = get(hit, '_source.visualization', { visState: '{}' }); diff --git a/src/setup_node_env/ensure_node_preserve_symlinks.js b/src/setup_node_env/ensure_node_preserve_symlinks.js index 0d72ec85e6c87..826244c4829fc 100644 --- a/src/setup_node_env/ensure_node_preserve_symlinks.js +++ b/src/setup_node_env/ensure_node_preserve_symlinks.js @@ -9,10 +9,51 @@ (function () { var cp = require('child_process'); + var calculateInspectPortOnExecArgv = function (processExecArgv) { + var execArgv = [].concat(processExecArgv); + + if (execArgv.length === 0) { + return execArgv; + } + + var inspectFlagIndex = execArgv.reverse().findIndex(function (flag) { + return flag.startsWith('--inspect'); + }); + + if (inspectFlagIndex !== -1) { + var inspectFlag; + var inspectPortCounter = 9230; + var argv = execArgv[inspectFlagIndex]; + + if (argv.includes('=')) { + // --inspect=port + var argvSplit = argv.split('='); + var flag = argvSplit[0]; + var port = argvSplit[1]; + inspectFlag = flag; + inspectPortCounter = Number.parseInt(port, 10) + 1; + } else { + // --inspect + inspectFlag = argv; + + // is number? + if (String(execArgv[inspectFlagIndex + 1]).match(/^[0-9]+$/)) { + // --inspect port + inspectPortCounter = Number.parseInt(execArgv[inspectFlagIndex + 1], 10) + 1; + execArgv.slice(inspectFlagIndex + 1, 1); + } + } + + execArgv[inspectFlagIndex] = inspectFlag + '=' + inspectPortCounter; + } + + return execArgv; + }; + var preserveSymlinksOption = '--preserve-symlinks'; var preserveSymlinksMainOption = '--preserve-symlinks-main'; var nodeOptions = (process && process.env && process.env.NODE_OPTIONS) || []; - var nodeExecArgv = (process && process.execArgv) || []; + var nodeExecArgv = calculateInspectPortOnExecArgv((process && process.execArgv) || []); var isPreserveSymlinksPresent = nodeOptions.includes(preserveSymlinksOption) || nodeExecArgv.includes(preserveSymlinksOption); diff --git a/src/setup_node_env/index.js b/src/setup_node_env/index.js index 08664344db393..9ce60766997cc 100644 --- a/src/setup_node_env/index.js +++ b/src/setup_node_env/index.js @@ -7,4 +7,5 @@ */ require('./no_transpilation'); +// eslint-disable-next-line import/no-extraneous-dependencies require('@kbn/optimizer').registerNodeAutoTranspilation(); diff --git a/test/api_integration/apis/home/sample_data.ts b/test/api_integration/apis/home/sample_data.ts index b889b59fdaf32..99327901ec8c3 100644 --- a/test/api_integration/apis/home/sample_data.ts +++ b/test/api_integration/apis/home/sample_data.ts @@ -48,12 +48,12 @@ export default function ({ getService }: FtrProviderContext) { }); it('should load elasticsearch index containing sample data with dates relative to current time', async () => { - const { body: resp } = await es.search({ + const { body: resp } = await es.search<{ timestamp: string }>({ index: 'kibana_sample_data_flights', }); const doc = resp.hits.hits[0]; - const docMilliseconds = Date.parse(doc._source.timestamp); + const docMilliseconds = Date.parse(doc._source!.timestamp); const nowMilliseconds = Date.now(); const delta = Math.abs(nowMilliseconds - docMilliseconds); expect(delta).to.be.lessThan(MILLISECOND_IN_WEEK * 4); @@ -66,12 +66,12 @@ export default function ({ getService }: FtrProviderContext) { .post(`/api/sample_data/flights?now=${nowString}`) .set('kbn-xsrf', 'kibana'); - const { body: resp } = await es.search({ + const { body: resp } = await es.search<{ timestamp: string }>({ index: 'kibana_sample_data_flights', }); const doc = resp.hits.hits[0]; - const docMilliseconds = Date.parse(doc._source.timestamp); + const docMilliseconds = Date.parse(doc._source!.timestamp); const nowMilliseconds = Date.parse(nowString); const delta = Math.abs(nowMilliseconds - docMilliseconds); expect(delta).to.be.lessThan(MILLISECOND_IN_WEEK * 4); diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 1f1f1a5c98cd6..87997ab4231a2 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -15,7 +15,7 @@ import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; import expect from '@kbn/expect'; import { ElasticsearchClient, SavedObjectsType } from 'src/core/server'; -import { SearchResponse } from '../../../../src/core/server/elasticsearch/client'; + import { DocumentMigrator, IndexMigrator, @@ -113,7 +113,7 @@ export default ({ getService }: FtrProviderContext) => { await esClient.indices.putTemplate({ name: 'migration_test_a_template', body: { - index_patterns: 'migration_test_a', + index_patterns: ['migration_test_a'], mappings: { dynamic: 'strict', properties: { baz: { type: 'text' } }, @@ -125,7 +125,7 @@ export default ({ getService }: FtrProviderContext) => { await esClient.indices.putTemplate({ name: 'migration_a_template', body: { - index_patterns: index, + index_patterns: [index], mappings: { dynamic: 'strict', properties: { baz: { type: 'text' } }, @@ -744,7 +744,7 @@ async function migrateIndex({ } async function fetchDocs(esClient: ElasticsearchClient, index: string) { - const { body } = await esClient.search>({ index }); + const { body } = await esClient.search({ index }); return body.hits.hits .map((h) => ({ diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index a7b4da566b143..d0a09ee58d335 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -156,7 +156,7 @@ export default function ({ getService }: FtrProviderContext) { describe('application usage limits', () => { function createSavedObject(viewId?: string) { return supertest - .post('/api/saved_objects/application_usage_transactional') + .post('/api/saved_objects/application_usage_daily') .send({ attributes: { appId: 'test-app', @@ -184,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { await Promise.all( savedObjectIds.map((savedObjectId) => { return supertest - .delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`) + .delete(`/api/saved_objects/application_usage_daily/${savedObjectId}`) .expect(200); }) ); @@ -230,7 +230,7 @@ export default function ({ getService }: FtrProviderContext) { .post('/api/saved_objects/_bulk_create') .send( new Array(10001).fill(0).map(() => ({ - type: 'application_usage_transactional', + type: 'application_usage_daily', attributes: { appId: 'test-app', minutesOnScreen: 1, @@ -248,13 +248,12 @@ export default function ({ getService }: FtrProviderContext) { // The SavedObjects API does not allow bulk deleting, and deleting one by one takes ages and the tests timeout await es.deleteByQuery({ index: '.kibana', - body: { query: { term: { type: 'application_usage_transactional' } } }, + body: { query: { term: { type: 'application_usage_daily' } } }, conflicts: 'proceed', }); }); - // flaky https://github.com/elastic/kibana/issues/94513 - it.skip("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { + it("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { const stats = await retrieveTelemetry(supertest); expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ 'test-app': { diff --git a/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts index 2c58794c96eca..a76d09481eca1 100644 --- a/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts +++ b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts @@ -26,15 +26,13 @@ export default function optInTest({ getService }: FtrProviderContext) { await supertest.put('/api/telemetry/v2/userHasSeenNotice').set('kbn-xsrf', 'xxx').expect(200); const { - body: { - _source: { telemetry }, - }, - } = await client.get({ + body: { _source }, + } = await client.get<{ telemetry: { userHasSeenNotice: boolean } }>({ index: '.kibana', id: 'telemetry:telemetry', }); - expect(telemetry.userHasSeenNotice).to.be(true); + expect(_source?.telemetry.userHasSeenNotice).to.be(true); }); }); } diff --git a/test/api_integration/apis/ui_metric/ui_metric.ts b/test/api_integration/apis/ui_metric/ui_metric.ts index 99007376e1ea4..47d10da9a1b29 100644 --- a/test/api_integration/apis/ui_metric/ui_metric.ts +++ b/test/api_integration/apis/ui_metric/ui_metric.ts @@ -102,12 +102,12 @@ export default function ({ getService }: FtrProviderContext) { body: { hits: { hits }, }, - } = await es.search({ index: '.kibana', q: 'type:ui-metric' }); + } = await es.search({ index: '.kibana', q: 'type:ui-metric' }); const countTypeEvent = hits.find( (hit: { _id: string }) => hit._id === `ui-metric:myApp:${uniqueEventName}` ); - expect(countTypeEvent._source['ui-metric'].count).to.eql(3); + expect(countTypeEvent?._source['ui-metric'].count).to.eql(3); }); }); } diff --git a/test/common/services/elasticsearch.ts b/test/common/services/elasticsearch.ts index 99335f8405828..7b8ff6bd6c8f4 100644 --- a/test/common/services/elasticsearch.ts +++ b/test/common/services/elasticsearch.ts @@ -10,10 +10,14 @@ import { format as formatUrl } from 'url'; import fs from 'fs'; import { Client } from '@elastic/elasticsearch'; import { CA_CERT_PATH } from '@kbn/dev-utils'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { FtrProviderContext } from '../ftr_provider_context'; -export function ElasticsearchProvider({ getService }: FtrProviderContext) { +/* + registers Kibana-specific @elastic/elasticsearch client instance. + */ +export function ElasticsearchProvider({ getService }: FtrProviderContext): KibanaClient { const config = getService('config'); if (process.env.TEST_CLOUD) { diff --git a/test/functional/apps/discover/_data_grid_context.ts b/test/functional/apps/discover/_data_grid_context.ts index 896cd4ad595c9..326fba9e6c087 100644 --- a/test/functional/apps/discover/_data_grid_context.ts +++ b/test/functional/apps/discover/_data_grid_context.ts @@ -34,7 +34,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardAddPanel = getService('dashboardAddPanel'); const browser = getService('browser'); - describe('discover data grid context tests', () => { + // FLAKY: https://github.com/elastic/kibana/issues/94545 + describe.skip('discover data grid context tests', () => { before(async () => { await esArchiver.load('discover'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index aeb02e5c30eb8..def175474d40e 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -105,21 +105,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should modify the time range when the histogram is brushed', async function () { // this is the number of renderings of the histogram needed when new data is fetched // this needs to be improved - const renderingCountInc = 3; + const renderingCountInc = 1; const prevRenderingCount = await elasticChart.getVisualizationRenderingCount(); await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('chart rendering complete', async () => { - const actualRenderingCount = await elasticChart.getVisualizationRenderingCount(); - log.debug(`Number of renderings before brushing: ${actualRenderingCount}`); - return actualRenderingCount === prevRenderingCount + renderingCountInc; + const actualCount = await elasticChart.getVisualizationRenderingCount(); + const expectedCount = prevRenderingCount + renderingCountInc; + log.debug( + `renderings before brushing - actual: ${actualCount} expected: ${expectedCount}` + ); + return actualCount === expectedCount; }); await PageObjects.discover.brushHistogram(); await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('chart rendering complete after being brushed', async () => { - const actualRenderingCount = await elasticChart.getVisualizationRenderingCount(); - log.debug(`Number of renderings after brushing: ${actualRenderingCount}`); - return actualRenderingCount === prevRenderingCount + 6; + const actualCount = await elasticChart.getVisualizationRenderingCount(); + const expectedCount = prevRenderingCount + renderingCountInc * 2; + log.debug( + `renderings after brushing - actual: ${actualCount} expected: ${expectedCount}` + ); + return actualCount === expectedCount; }); const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); expect(Math.round(newDurationHours)).to.be(26); diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts index 3febeb06fd600..edcb002000183 100644 --- a/test/functional/apps/discover/_doc_table.ts +++ b/test/functional/apps/discover/_doc_table.ts @@ -65,6 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const finalRows = await PageObjects.discover.getDocTableRows(); expect(finalRows.length).to.be.above(initialRows.length); expect(finalRows.length).to.be(rowsHardLimit); + await PageObjects.discover.backToTop(); }); it('should go the end of the table when using the accessible Skip button', async function () { @@ -74,6 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const footer = await PageObjects.discover.getDocTableFooter(); log.debug(await footer.getVisibleText()); expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); + await PageObjects.discover.backToTop(); }); describe('expand a document row', function () { diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 23f3af37bbdf6..9726b097c8f62 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); - // FLAKY: https://github.com/elastic/kibana/issues/89477 - describe.skip('saved queries saved objects', function describeIndexTests() { + describe('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); @@ -120,6 +119,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('does not allow saving a query with a non-unique name', async () => { + // this check allows this test to run stand alone, also should fix occacional flakiness + const savedQueryExists = await savedQueryManagementComponent.savedQueryExist('OkResponse'); + if (!savedQueryExists) { + await savedQueryManagementComponent.saveNewQuery( + 'OkResponse', + '200 responses for .jpg over 24 hours', + true, + true + ); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + } await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); }); diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index 4b3533f20c8dc..e3ff1819aed13 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); + const testSubjects = getService('testSubjects'); describe('runtime fields', function () { this.tags(['skipFirefox']); @@ -47,6 +48,20 @@ export default function ({ getService, getPageObjects }) { expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); }); }); + + it('should modify runtime field', async function () { + await PageObjects.settings.filterField(fieldName); + await testSubjects.click('editFieldFormat'); + await PageObjects.settings.setFieldType('Long'); + await PageObjects.settings.changeFieldScript('emit(6);'); + await PageObjects.settings.clickSaveField(); + await PageObjects.settings.confirmSave(); + }); + + it('should delete runtime field', async function () { + await testSubjects.click('deleteField'); + await PageObjects.settings.confirmDelete(); + }); }); }); } diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index ba500904d75c7..80ab33170c396 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -107,33 +107,48 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('switch index patterns', () => { + before(async () => { + await esArchiver.loadIfNeeded('index_pattern_without_timefield'); + }); + beforeEach(async () => { - log.debug('Load kibana_sample_data_flights data'); - await esArchiver.loadIfNeeded('kibana_sample_data_flights'); await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 22, 2019 @ 00:00:00.000', + 'Sep 23, 2019 @ 00:00:00.000' + ); }); + after(async () => { await security.testUser.restoreDefaults(); - await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('index_pattern_without_timefield'); }); - it('should be able to switch between index patterns', async () => { - const value = await PageObjects.visualBuilder.getMetricValue(); - expect(value).to.eql('156'); + const switchIndexTest = async (useKibanaIndexes: boolean) => { await PageObjects.visualBuilder.clickPanelOptions('metric'); - const fromTime = 'Oct 22, 2018 @ 00:00:00.000'; - const toTime = 'Oct 28, 2018 @ 23:59:59.999'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.visualBuilder.setIndexPatternValue('', false); + + const value = await PageObjects.visualBuilder.getMetricValue(); + expect(value).to.eql('0'); + // Sometimes popovers take some time to appear in Firefox (#71979) await retry.tryForTime(20000, async () => { - await PageObjects.visualBuilder.setIndexPatternValue('kibana_sample_data_flights'); + await PageObjects.visualBuilder.setIndexPatternValue('with-timefield', useKibanaIndexes); await PageObjects.visualBuilder.waitForIndexPatternTimeFieldOptionsLoaded(); await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); }); const newValue = await PageObjects.visualBuilder.getMetricValue(); - expect(newValue).to.eql('18'); + expect(newValue).to.eql('1'); + }; + + it('should be able to switch using text mode selection', async () => { + await switchIndexTest(false); + }); + + it('should be able to switch combo box mode selection', async () => { + await switchIndexTest(true); }); }); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index c6412f55dffbf..6d9641a1a920e 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -463,6 +463,21 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo async getWelcomeText() { return await testSubjects.getVisibleText('global-banner-item'); } + + /** + * Clicks on an element, and validates that the desired effect has taken place + * by confirming the existence of a validator + */ + async clickAndValidate( + clickTarget: string, + validator: string, + isValidatorCssString: boolean = false, + topOffset?: number + ) { + await testSubjects.click(clickTarget, undefined, topOffset); + const validate = isValidatorCssString ? find.byCssSelector : testSubjects.exists; + await validate(validator); + } } return new CommonPage(); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 733f5cb59fbbb..32288239f9848 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -210,6 +210,15 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return skipButton.click(); } + /** + * When scrolling down the legacy table there's a link to scroll up + * So this is done by this function + */ + public async backToTop() { + const skipButton = await testSubjects.find('discoverBackToTop'); + return skipButton.click(); + } + public async getDocTableFooter() { return await testSubjects.find('discoverDocTableFooter'); } diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 4151a8c1a1893..14bd002ec9487 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -502,6 +502,16 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await this.closeIndexPatternFieldEditor(); } + public async confirmSave() { + await testSubjects.setValue('saveModalConfirmText', 'change'); + await testSubjects.click('confirmModalConfirmButton'); + } + + public async confirmDelete() { + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); + } + async closeIndexPatternFieldEditor() { await retry.waitFor('field editor flyout to close', async () => { return !(await testSubjects.exists('euiFlyoutCloseButton')); @@ -543,6 +553,17 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider browser.pressKeys(script); } + async changeFieldScript(script: string) { + log.debug('set script = ' + script); + const formatRow = await testSubjects.find('valueRow'); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + browser.pressKeys(browser.keys.DELETE.repeat(30)); + browser.pressKeys(script); + } + async clickAddScriptedField() { log.debug('click Add Scripted Field'); await testSubjects.click('addScriptedFieldLink'); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index d7bb84394ae3c..90873d72234d0 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -431,10 +431,34 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro await PageObjects.header.waitUntilLoadingHasFinished(); } - public async setIndexPatternValue(value: string) { - const el = await testSubjects.find('metricsIndexPatternInput'); - await el.clearValue(); - await el.type(value, { charByChar: true }); + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { + await testSubjects.click('switchIndexPatternSelectionModePopover'); + await testSubjects.setEuiSwitch( + 'switchIndexPatternSelectionMode', + useKibanaIndices ? 'check' : 'uncheck' + ); + } + + public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { + const metricsIndexPatternInput = 'metricsIndexPatternInput'; + + if (useKibanaIndices !== undefined) { + await this.switchIndexPatternSelectionMode(useKibanaIndices); + } + + if (useKibanaIndices === false) { + const el = await testSubjects.find(metricsIndexPatternInput); + await el.clearValue(); + if (value) { + await el.type(value, { charByChar: true }); + } + } else { + await comboBox.clearInputField(metricsIndexPatternInput); + if (value) { + await comboBox.setCustom(metricsIndexPatternInput, value); + } + } + await PageObjects.header.waitUntilLoadingHasFinished(); } diff --git a/test/functional/services/common/find.ts b/test/functional/services/common/find.ts index 2a86efad1ea9d..0cd4c14683f6e 100644 --- a/test/functional/services/common/find.ts +++ b/test/functional/services/common/find.ts @@ -79,11 +79,11 @@ export async function FindProvider({ getService }: FtrProviderContext) { return wrap(await driver.switchTo().activeElement()); } - public async setValue(selector: string, text: string): Promise { + public async setValue(selector: string, text: string, topOffset?: number): Promise { log.debug(`Find.setValue('${selector}', '${text}')`); return await retry.try(async () => { const element = await this.byCssSelector(selector); - await element.click(); + await element.click(topOffset); // in case the input element is actually a child of the testSubject, we // call clearValue() and type() on the element that is focused after @@ -413,14 +413,15 @@ export async function FindProvider({ getService }: FtrProviderContext) { public async clickByCssSelector( selector: string, - timeout: number = defaultFindTimeout + timeout: number = defaultFindTimeout, + topOffset?: number ): Promise { log.debug(`Find.clickByCssSelector('${selector}') with timeout=${timeout}`); await retry.try(async () => { const element = await this.byCssSelector(selector, timeout); if (element) { // await element.moveMouseTo(); - await element.click(); + await element.click(topOffset); } else { throw new Error(`Element with css='${selector}' is not found`); } diff --git a/test/functional/services/common/test_subjects.ts b/test/functional/services/common/test_subjects.ts index 28b37d9576e8c..111206ec9eafe 100644 --- a/test/functional/services/common/test_subjects.ts +++ b/test/functional/services/common/test_subjects.ts @@ -100,9 +100,13 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { await find.clickByCssSelectorWhenNotDisabled(testSubjSelector(selector), { timeout }); } - public async click(selector: string, timeout: number = FIND_TIME): Promise { + public async click( + selector: string, + timeout: number = FIND_TIME, + topOffset?: number + ): Promise { log.debug(`TestSubjects.click(${selector})`); - await find.clickByCssSelector(testSubjSelector(selector), timeout); + await find.clickByCssSelector(testSubjSelector(selector), timeout, topOffset); } public async doubleClick(selector: string, timeout: number = FIND_TIME): Promise { @@ -187,12 +191,13 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { public async setValue( selector: string, text: string, - options: SetValueOptions = {} + options: SetValueOptions = {}, + topOffset?: number ): Promise { return await retry.try(async () => { const { clearWithKeyboard = false, typeCharByChar = false } = options; log.debug(`TestSubjects.setValue(${selector}, ${text})`); - await this.click(selector); + await this.click(selector, undefined, topOffset); // in case the input element is actually a child of the testSubject, we // call clearValue() and type() on the element that is focused after // clicking on the testSubject diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index 5cd1f2c4f6202..7d6dad4f7858e 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function FieldEditorProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); - const retry = getService('retry'); const testSubjects = getService('testSubjects'); class FieldEditor { @@ -33,10 +32,17 @@ export function FieldEditorProvider({ getService }: FtrProviderContext) { await browser.pressKeys(script); } public async save() { - await retry.try(async () => { - await testSubjects.click('fieldSaveButton'); - await testSubjects.missingOrFail('fieldSaveButton', { timeout: 2000 }); - }); + await testSubjects.click('fieldSaveButton'); + } + + public async confirmSave() { + await testSubjects.setValue('saveModalConfirmText', 'change'); + await testSubjects.click('confirmModalConfirmButton'); + } + + public async confirmDelete() { + await testSubjects.setValue('deleteModalConfirmText', 'remove'); + await testSubjects.click('confirmModalConfirmButton'); } } diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index 1a45aee877e1f..b1561b29342da 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -182,9 +182,9 @@ export class WebElementWrapper { * * @return {Promise} */ - public async click() { + public async click(topOffset?: number) { await this.retryCall(async function click(wrapper) { - await wrapper.scrollIntoViewIfNecessary(); + await wrapper.scrollIntoViewIfNecessary(topOffset); await wrapper._webElement.click(); }); } @@ -693,11 +693,11 @@ export class WebElementWrapper { * @nonstandard * @return {Promise} */ - public async scrollIntoViewIfNecessary(): Promise { + public async scrollIntoViewIfNecessary(topOffset?: number): Promise { await this.driver.executeScript( scrollIntoViewIfNecessary, this._webElement, - this.fixedHeaderHeight + topOffset || this.fixedHeaderHeight ); } diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index a39032af43295..7398e6ca8c12e 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -139,6 +139,13 @@ export function SavedQueryManagementComponentProvider({ await testSubjects.click('savedQueryFormSaveButton'); } + async savedQueryExist(title: string) { + await this.openSavedQueryManagementComponent(); + const exists = testSubjects.exists(`~load-saved-query-${title}-button`); + await this.closeSavedQueryManagementComponent(); + return exists; + } + async savedQueryExistOrFail(title: string) { await this.openSavedQueryManagementComponent(); await testSubjects.existOrFail(`~load-saved-query-${title}-button`); diff --git a/test/functional/services/visualizations/elastic_chart.ts b/test/functional/services/visualizations/elastic_chart.ts index 010394b275228..80483100a06dd 100644 --- a/test/functional/services/visualizations/elastic_chart.ts +++ b/test/functional/services/visualizations/elastic_chart.ts @@ -29,7 +29,11 @@ export function ElasticChartProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); class ElasticChart { - public async getCanvas() { + public async getCanvas(dataTestSubj?: string) { + if (dataTestSubj) { + const chart = await this.getChart(dataTestSubj); + return await chart.findByClassName('echCanvasRenderer'); + } return await find.byCssSelector('.echChart canvas:last-of-type'); } diff --git a/test/scripts/test/jest_unit.sh b/test/scripts/test/jest_unit.sh index fd1166b07f322..74d3c5b0cad18 100755 --- a/test/scripts/test/jest_unit.sh +++ b/test/scripts/test/jest_unit.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose --maxWorkers=8 + node scripts/jest --ci --verbose --maxWorkers=6 diff --git a/typings/elasticsearch/aggregations.d.ts b/typings/elasticsearch/aggregations.d.ts deleted file mode 100644 index 2b501c94889f4..0000000000000 --- a/typings/elasticsearch/aggregations.d.ts +++ /dev/null @@ -1,466 +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 { Unionize, UnionToIntersection } from 'utility-types'; -import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.'; - -export type SortOrder = 'asc' | 'desc'; -type SortInstruction = Record; -export type SortOptions = SortOrder | SortInstruction | SortInstruction[]; - -type Script = - | string - | { - lang?: string; - id?: string; - source?: string; - params?: Record; - }; - -type BucketsPath = string | Record; - -type AggregationSourceOptions = - | { - field: string; - missing?: unknown; - } - | { - script: Script; - }; - -interface MetricsAggregationResponsePart { - value: number | null; -} -interface DateHistogramBucket { - doc_count: number; - key: number; - key_as_string: string; -} - -type GetCompositeKeys< - TAggregationOptionsMap extends AggregationOptionsMap -> = TAggregationOptionsMap extends { - composite: { sources: Array }; -} - ? keyof Source - : never; - -type CompositeOptionsSource = Record< - string, - | { - terms: ({ field: string } | { script: Script }) & { - missing_bucket?: boolean; - }; - } - | undefined ->; - -export interface AggregationOptionsByType { - terms: { - size?: number; - order?: SortOptions; - execution_hint?: 'map' | 'global_ordinals'; - } & AggregationSourceOptions; - date_histogram: { - format?: string; - min_doc_count?: number; - extended_bounds?: { - min: number; - max: number; - }; - } & ({ calendar_interval: string } | { fixed_interval: string }) & - AggregationSourceOptions; - histogram: { - interval: number; - min_doc_count?: number; - extended_bounds?: { - min?: number | string; - max?: number | string; - }; - } & AggregationSourceOptions; - avg: AggregationSourceOptions; - max: AggregationSourceOptions; - min: AggregationSourceOptions; - sum: AggregationSourceOptions; - value_count: AggregationSourceOptions; - cardinality: AggregationSourceOptions & { - precision_threshold?: number; - }; - percentiles: { - percents?: number[]; - hdr?: { number_of_significant_value_digits: number }; - } & AggregationSourceOptions; - stats: { - field: string; - }; - extended_stats: { - field: string; - }; - string_stats: { field: string }; - top_hits: { - from?: number; - size?: number; - sort?: SortOptions; - _source?: ESSourceOptions; - fields?: MaybeReadonlyArray; - docvalue_fields?: MaybeReadonlyArray; - }; - filter: Record; - filters: { - filters: Record | any[]; - }; - sampler: { - shard_size?: number; - }; - derivative: { - buckets_path: BucketsPath; - }; - bucket_script: { - buckets_path: BucketsPath; - script?: Script; - }; - composite: { - size?: number; - sources: CompositeOptionsSource[]; - after?: Record; - }; - diversified_sampler: { - shard_size?: number; - max_docs_per_value?: number; - } & ({ script: Script } | { field: string }); // TODO use MetricsAggregationOptions if possible - scripted_metric: { - params?: Record; - init_script?: Script; - map_script: Script; - combine_script: Script; - reduce_script: Script; - }; - date_range: { - format?: string; - ranges: Array< - | { from: string | number } - | { to: string | number } - | { from: string | number; to: string | number } - >; - keyed?: boolean; - } & AggregationSourceOptions; - range: { - field: string; - ranges: Array< - | { key?: string; from: string | number } - | { key?: string; to: string | number } - | { key?: string; from: string | number; to: string | number } - >; - keyed?: boolean; - }; - auto_date_histogram: { - buckets: number; - } & AggregationSourceOptions; - percentile_ranks: { - values: Array; - keyed?: boolean; - hdr?: { number_of_significant_value_digits: number }; - } & AggregationSourceOptions; - bucket_sort: { - sort?: SortOptions; - from?: number; - size?: number; - }; - significant_terms: { - size?: number; - field?: string; - background_filter?: Record; - } & AggregationSourceOptions; - bucket_selector: { - buckets_path: { - [x: string]: string; - }; - script: string; - }; - top_metrics: { - metrics: { field: string } | MaybeReadonlyArray<{ field: string }>; - sort: SortOptions; - }; - avg_bucket: { - buckets_path: string; - gap_policy?: 'skip' | 'insert_zeros'; - format?: string; - }; - rate: { - unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; - } & ( - | { - field: string; - mode: 'sum' | 'value_count'; - } - | {} - ); -} - -type AggregationType = keyof AggregationOptionsByType; - -type AggregationOptionsMap = Unionize< - { - [TAggregationType in AggregationType]: AggregationOptionsByType[TAggregationType]; - } -> & { aggs?: AggregationInputMap }; - -interface DateRangeBucket { - key: string; - to?: number; - from?: number; - to_as_string?: string; - from_as_string?: string; - doc_count: number; -} - -export interface AggregationInputMap { - [key: string]: AggregationOptionsMap; -} - -type SubAggregationResponseOf< - TAggregationInputMap extends AggregationInputMap | undefined, - TDocument -> = TAggregationInputMap extends AggregationInputMap - ? AggregationResponseMap - : {}; - -interface AggregationResponsePart { - terms: { - buckets: Array< - { - doc_count: number; - key: string | number; - } & SubAggregationResponseOf - >; - doc_count_error_upper_bound?: number; - sum_other_doc_count?: number; - }; - histogram: { - buckets: Array< - { - doc_count: number; - key: number; - } & SubAggregationResponseOf - >; - }; - date_histogram: { - buckets: Array< - DateHistogramBucket & SubAggregationResponseOf - >; - }; - avg: MetricsAggregationResponsePart; - sum: MetricsAggregationResponsePart; - max: MetricsAggregationResponsePart; - min: MetricsAggregationResponsePart; - value_count: { value: number }; - cardinality: { - value: number; - }; - percentiles: { - values: Record; - }; - stats: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number | null; - }; - extended_stats: { - count: number; - min: number | null; - max: number | null; - avg: number | null; - sum: number | null; - sum_of_squares: number | null; - variance: number | null; - std_deviation: number | null; - std_deviation_bounds: { - upper: number | null; - lower: number | null; - }; - }; - string_stats: { - count: number; - min_length: number; - max_length: number; - avg_length: number; - entropy: number; - }; - top_hits: { - hits: { - total: { - value: number; - relation: 'eq' | 'gte'; - }; - max_score: number | null; - hits: TAggregationOptionsMap extends { top_hits: AggregationOptionsByType['top_hits'] } - ? ESHitsOf - : ESSearchHit[]; - }; - }; - filter: { - doc_count: number; - } & SubAggregationResponseOf; - filters: TAggregationOptionsMap extends { filters: { filters: any[] } } - ? Array< - { doc_count: number } & AggregationResponseMap - > - : TAggregationOptionsMap extends { - filters: { - filters: Record; - }; - } - ? { - buckets: { - [key in keyof TAggregationOptionsMap['filters']['filters']]: { - doc_count: number; - } & SubAggregationResponseOf; - }; - } - : never; - sampler: { - doc_count: number; - } & SubAggregationResponseOf; - derivative: - | { - value: number; - } - | undefined; - bucket_script: - | { - value: number | null; - } - | undefined; - composite: { - after_key: { - [key in GetCompositeKeys]: TAggregationOptionsMap; - }; - buckets: Array< - { - key: Record, string | number>; - doc_count: number; - } & SubAggregationResponseOf - >; - }; - diversified_sampler: { - doc_count: number; - } & AggregationResponseMap; - scripted_metric: { - value: unknown; - }; - date_range: { - buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } - ? Record - : { buckets: DateRangeBucket[] }; - }; - range: { - buckets: TAggregationOptionsMap extends { range: { keyed: true } } - ? Record< - string, - DateRangeBucket & SubAggregationResponseOf - > - : Array< - DateRangeBucket & SubAggregationResponseOf - >; - }; - auto_date_histogram: { - buckets: Array< - DateHistogramBucket & AggregationResponseMap - >; - interval: string; - }; - - percentile_ranks: { - values: TAggregationOptionsMap extends { - percentile_ranks: { keyed: false }; - } - ? Array<{ key: number; value: number }> - : Record; - }; - significant_terms: { - doc_count: number; - bg_count: number; - buckets: Array< - { - score: number; - bg_count: number; - doc_count: number; - key: string | number; - } & SubAggregationResponseOf - >; - }; - bucket_sort: undefined; - bucket_selector: undefined; - top_metrics: { - top: [ - { - sort: [string | number]; - metrics: UnionToIntersection< - TAggregationOptionsMap extends { - top_metrics: { metrics: { field: infer TFieldName } }; - } - ? TopMetricsMap - : TAggregationOptionsMap extends { - top_metrics: { metrics: MaybeReadonlyArray<{ field: infer TFieldName }> }; - } - ? TopMetricsMap - : TopMetricsMap - >; - } - ]; - }; - avg_bucket: { - value: number | null; - }; - rate: { - value: number | null; - }; -} - -type TopMetricsMap = TFieldName extends string - ? Record - : Record; - -// Type for debugging purposes. If you see an error in AggregationResponseMap -// similar to "cannot be used to index type", uncomment the type below and hover -// over it to see what aggregation response types are missing compared to the -// input map. - -// type MissingAggregationResponseTypes = Exclude< -// AggregationType, -// keyof AggregationResponsePart<{}, unknown> -// >; - -// ensures aggregations work with requests where aggregation options are a union type, -// e.g. { transaction_groups: { composite: any } | { terms: any } }. -// Union keys are not included in keyof. The type will fall back to keyof T if -// UnionToIntersection fails, which happens when there are conflicts between the union -// types, e.g. { foo: string; bar?: undefined } | { foo?: undefined; bar: string }; -export type ValidAggregationKeysOf< - T extends Record -> = keyof (UnionToIntersection extends never ? T : UnionToIntersection); - -export type AggregationResultOf< - TAggregationOptionsMap extends AggregationOptionsMap, - TDocument -> = AggregationResponsePart[AggregationType & - ValidAggregationKeysOf]; - -export type AggregationResponseMap< - TAggregationInputMap extends AggregationInputMap | undefined, - TDocument -> = TAggregationInputMap extends AggregationInputMap - ? { - [TName in keyof TAggregationInputMap]: AggregationResponsePart< - TAggregationInputMap[TName], - TDocument - >[AggregationType & ValidAggregationKeysOf]; - } - : undefined; diff --git a/typings/elasticsearch/index.d.ts b/typings/elasticsearch/index.d.ts index a84d4148f6fe7..7eaf762d353ac 100644 --- a/typings/elasticsearch/index.d.ts +++ b/typings/elasticsearch/index.d.ts @@ -5,136 +5,28 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { estypes } from '@elastic/elasticsearch'; +import { InferSearchResponseOf, AggregateOf as AggregationResultOf, SearchHit } from './search'; -import { ValuesType } from 'utility-types'; -import { Explanation, SearchParams, SearchResponse } from 'elasticsearch'; -import { RequestParams } from '@elastic/elasticsearch'; -import { AggregationResponseMap, AggregationInputMap, SortOptions } from './aggregations'; -export { - AggregationInputMap, - AggregationOptionsByType, - AggregationResponseMap, - AggregationResultOf, - SortOptions, - ValidAggregationKeysOf, -} from './aggregations'; +export type ESFilter = estypes.QueryContainer; +export type ESSearchRequest = estypes.SearchRequest; +export type AggregationOptionsByType = Required; // Typings for Elasticsearch queries and aggregations. These are intended to be // moved to the Elasticsearch JS client at some point (see #77720.) export type MaybeReadonlyArray = T[] | readonly T[]; -interface CollapseQuery { - field: string; - inner_hits?: { - name: string; - size?: number; - sort?: SortOptions; - _source?: - | string - | string[] - | { - includes?: string | string[]; - excludes?: string | string[]; - }; - collapse?: { - field: string; - }; - }; - max_concurrent_group_searches?: number; -} - export type ESSourceOptions = boolean | string | string[]; -export type ESHitsOf< - TOptions extends - | { - size?: number; - _source?: ESSourceOptions; - docvalue_fields?: MaybeReadonlyArray; - fields?: MaybeReadonlyArray; - } - | undefined, - TDocument extends unknown -> = Array< - ESSearchHit< - TOptions extends { _source: false } ? undefined : TDocument, - TOptions extends { fields: MaybeReadonlyArray } ? TOptions['fields'] : undefined, - TOptions extends { docvalue_fields: MaybeReadonlyArray } - ? TOptions['docvalue_fields'] - : undefined - > ->; - -export interface ESSearchBody { - query?: any; - size?: number; - from?: number; - aggs?: AggregationInputMap; - track_total_hits?: boolean | number; - collapse?: CollapseQuery; - search_after?: Array; - _source?: ESSourceOptions; -} - -export type ESSearchRequest = RequestParams.Search; - export interface ESSearchOptions { restTotalHitsAsInt: boolean; } -export type ESSearchHit< - TSource extends any = unknown, - TFields extends MaybeReadonlyArray | undefined = undefined, - TDocValueFields extends MaybeReadonlyArray | undefined = undefined -> = { - _index: string; - _type: string; - _id: string; - _score: number; - _version?: number; - _explanation?: Explanation; - highlight?: any; - inner_hits?: any; - matched_queries?: string[]; - sort?: string[]; -} & (TSource extends false ? {} : { _source: TSource }) & - (TFields extends MaybeReadonlyArray - ? { - fields: Partial, unknown[]>>; - } - : {}) & - (TDocValueFields extends MaybeReadonlyArray - ? { - fields: Partial, unknown[]>>; - } - : {}); - export type ESSearchResponse< - TDocument, - TSearchRequest extends ESSearchRequest, - TOptions extends ESSearchOptions = { restTotalHitsAsInt: false } -> = Omit, 'aggregations' | 'hits'> & - (TSearchRequest extends { body: { aggs: AggregationInputMap } } - ? { - aggregations?: AggregationResponseMap; - } - : {}) & { - hits: Omit['hits'], 'total' | 'hits'> & - (TOptions['restTotalHitsAsInt'] extends true - ? { - total: number; - } - : { - total: { - value: number; - relation: 'eq' | 'gte'; - }; - }) & { hits: ESHitsOf }; - }; + TDocument = unknown, + TSearchRequest extends ESSearchRequest = ESSearchRequest, + TOptions extends { restTotalHitsAsInt: boolean } = { restTotalHitsAsInt: false } +> = InferSearchResponseOf; -export interface ESFilter { - [key: string]: { - [key: string]: string | string[] | number | boolean | Record | ESFilter[]; - }; -} +export { InferSearchResponseOf, AggregationResultOf, SearchHit }; diff --git a/typings/elasticsearch/search.d.ts b/typings/elasticsearch/search.d.ts new file mode 100644 index 0000000000000..fce08df1c0a04 --- /dev/null +++ b/typings/elasticsearch/search.d.ts @@ -0,0 +1,577 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ValuesType } from 'utility-types'; +import { estypes } from '@elastic/elasticsearch'; + +type InvalidAggregationRequest = unknown; + +// ensures aggregations work with requests where aggregation options are a union type, +// e.g. { transaction_groups: { composite: any } | { terms: any } }. +// Union keys are not included in keyof, but extends iterates over the types in a union. +type ValidAggregationKeysOf> = T extends T ? keyof T : never; + +type KeyOfSource = Record< + keyof T, + (T extends Record ? null : never) | string | number +>; + +type KeysOfSources = T extends [infer U, ...infer V] + ? KeyOfSource & KeysOfSources + : T extends Array + ? KeyOfSource + : {}; + +type CompositeKeysOf< + TAggregationContainer extends estypes.AggregationContainer +> = TAggregationContainer extends { + composite: { sources: [...infer TSource] }; +} + ? KeysOfSources + : unknown; + +type Source = estypes.SourceFilter | boolean | estypes.Fields; + +type ValueTypeOfField = T extends Record + ? ValuesType + : T extends string[] | number[] + ? ValueTypeOfField> + : T extends { field: estypes.Field } + ? T['field'] + : T extends string | number + ? T + : never; + +type MaybeArray = T | T[]; + +type Fields = MaybeArray; +type DocValueFields = MaybeArray; + +export type SearchHit< + TSource extends any = unknown, + TFields extends Fields | undefined = undefined, + TDocValueFields extends DocValueFields | undefined = undefined +> = Omit & + (TSource extends false ? {} : { _source: TSource }) & + (TFields extends estypes.Fields + ? { + fields: Partial, unknown[]>>; + } + : {}) & + (TDocValueFields extends DocValueFields + ? { + fields: Partial, unknown[]>>; + } + : {}); + +type HitsOf< + TOptions extends + | { _source?: Source; fields?: Fields; docvalue_fields?: DocValueFields } + | undefined, + TDocument extends unknown +> = Array< + SearchHit< + TOptions extends { _source: false } ? undefined : TDocument, + TOptions extends { fields: estypes.Fields } ? TOptions['fields'] : undefined, + TOptions extends { docvalue_fields: DocValueFields } ? TOptions['docvalue_fields'] : undefined + > +>; + +type AggregationTypeName = Exclude; + +type AggregationMap = Partial>; + +type TopLevelAggregationRequest = Pick; + +type MaybeKeyed< + TAggregationContainer, + TBucket, + TKeys extends string = string +> = TAggregationContainer extends Record + ? Record + : { buckets: TBucket[] }; + +export type AggregateOf< + TAggregationContainer extends estypes.AggregationContainer, + TDocument +> = (Record & { + adjacency_matrix: { + buckets: Array< + { + key: string; + doc_count: number; + } & SubAggregateOf + >; + }; + auto_date_histogram: { + interval: string; + buckets: Array< + { + key: number; + key_as_string: string; + doc_count: number; + } & SubAggregateOf + >; + }; + avg: { + value: number | null; + value_as_string?: string; + }; + avg_bucket: { + value: number | null; + }; + boxplot: { + min: number | null; + max: number | null; + q1: number | null; + q2: number | null; + q3: number | null; + }; + bucket_script: { + value: unknown; + }; + cardinality: { + value: number; + }; + children: { + doc_count: number; + } & SubAggregateOf; + composite: { + after_key: CompositeKeysOf; + buckets: Array< + { + doc_count: number; + key: CompositeKeysOf; + } & SubAggregateOf + >; + }; + cumulative_cardinality: { + value: number; + }; + cumulative_sum: { + value: number; + }; + date_histogram: MaybeKeyed< + TAggregationContainer, + { + key: number; + key_as_string: string; + doc_count: number; + } & SubAggregateOf + >; + date_range: MaybeKeyed< + TAggregationContainer, + Partial<{ from: string | number; from_as_string: string }> & + Partial<{ to: string | number; to_as_string: string }> & { + doc_count: number; + key: string; + } + >; + derivative: + | { + value: number | null; + } + | undefined; + extended_stats: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + sum_of_squares: number | null; + variance: number | null; + variance_population: number | null; + variance_sampling: number | null; + std_deviation: number | null; + std_deviation_population: number | null; + std_deviation_sampling: number | null; + std_deviation_bounds: { + upper: number | null; + lower: number | null; + upper_population: number | null; + lower_population: number | null; + upper_sampling: number | null; + lower_sampling: number | null; + }; + } & ( + | { + min_as_string: string; + max_as_string: string; + avg_as_string: string; + sum_of_squares_as_string: string; + variance_population_as_string: string; + variance_sampling_as_string: string; + std_deviation_as_string: string; + std_deviation_population_as_string: string; + std_deviation_sampling_as_string: string; + std_deviation_bounds_as_string: { + upper: string; + lower: string; + upper_population: string; + lower_population: string; + upper_sampling: string; + lower_sampling: string; + }; + } + | {} + ); + extended_stats_bucket: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number | null; + sum_of_squares: number | null; + variance: number | null; + variance_population: number | null; + variance_sampling: number | null; + std_deviation: number | null; + std_deviation_population: number | null; + std_deviation_sampling: number | null; + std_deviation_bounds: { + upper: number | null; + lower: number | null; + upper_population: number | null; + lower_population: number | null; + upper_sampling: number | null; + lower_sampling: number | null; + }; + }; + filter: { + doc_count: number; + } & SubAggregateOf; + filters: { + buckets: TAggregationContainer extends { filters: { filters: any[] } } + ? Array< + { + doc_count: number; + } & SubAggregateOf + > + : TAggregationContainer extends { filters: { filters: Record } } + ? { + [key in keyof TAggregationContainer['filters']['filters']]: { + doc_count: number; + } & SubAggregateOf; + } & + (TAggregationContainer extends { filters: { other_bucket_key: infer TOtherBucketKey } } + ? Record< + TOtherBucketKey & string, + { doc_count: number } & SubAggregateOf + > + : unknown) & + (TAggregationContainer extends { filters: { other_bucket: true } } + ? { _other: { doc_count: number } & SubAggregateOf } + : unknown) + : unknown; + }; + geo_bounds: { + top_left: { + lat: number | null; + lon: number | null; + }; + bottom_right: { + lat: number | null; + lon: number | null; + }; + }; + geo_centroid: { + count: number; + location: { + lat: number; + lon: number; + }; + }; + geo_distance: MaybeKeyed< + TAggregationContainer, + { + from: number; + to?: number; + doc_count: number; + } & SubAggregateOf + >; + geo_hash: { + buckets: Array< + { + doc_count: number; + key: string; + } & SubAggregateOf + >; + }; + geotile_grid: { + buckets: Array< + { + doc_count: number; + key: string; + } & SubAggregateOf + >; + }; + global: { + doc_count: number; + } & SubAggregateOf; + histogram: MaybeKeyed< + TAggregationContainer, + { + key: number; + doc_count: number; + } & SubAggregateOf + >; + ip_range: MaybeKeyed< + TAggregationContainer, + { + key: string; + from?: string; + to?: string; + doc_count: number; + }, + TAggregationContainer extends { ip_range: { ranges: Array } } + ? TRangeType extends { key: infer TKeys } + ? TKeys + : string + : string + >; + inference: { + value: number; + prediction_probability: number; + prediction_score: number; + }; + max: { + value: number | null; + value_as_string?: string; + }; + max_bucket: { + value: number | null; + }; + min: { + value: number | null; + value_as_string?: string; + }; + min_bucket: { + value: number | null; + }; + median_absolute_deviation: { + value: number | null; + }; + moving_avg: + | { + value: number | null; + } + | undefined; + moving_fn: { + value: number | null; + }; + moving_percentiles: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record | undefined; + missing: { + doc_count: number; + } & SubAggregateOf; + nested: { + doc_count: number; + } & SubAggregateOf; + normalize: { + value: number | null; + // TODO: should be perhaps based on input? ie when `format` is specified + value_as_string?: string; + }; + parent: { + doc_count: number; + } & SubAggregateOf; + percentiles: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + percentile_ranks: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + percentiles_bucket: { + values: TAggregationContainer extends Record + ? Array<{ key: number; value: number | null }> + : Record; + }; + range: MaybeKeyed< + TAggregationContainer, + { + key: string; + from?: number; + to?: number; + doc_count: number; + }, + TAggregationContainer extends { range: { ranges: Array } } + ? TRangeType extends { key: infer TKeys } + ? TKeys + : string + : string + >; + rare_terms: Array< + { + key: string | number; + doc_count: number; + } & SubAggregateOf + >; + rate: { + value: number | null; + }; + reverse_nested: { + doc_count: number; + } & SubAggregateOf; + sampler: { + doc_count: number; + } & SubAggregateOf; + scripted_metric: { + value: unknown; + }; + serial_diff: { + value: number | null; + // TODO: should be perhaps based on input? ie when `format` is specified + value_as_string?: string; + }; + significant_terms: { + doc_count: number; + bg_count: number; + buckets: Array< + { + key: string | number; + score: number; + doc_count: number; + bg_count: number; + } & SubAggregateOf + >; + }; + significant_text: { + doc_count: number; + buckets: Array<{ + key: string; + doc_count: number; + score: number; + bg_count: number; + }>; + }; + stats: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + } & ( + | { + min_as_string: string; + max_as_string: string; + avg_as_string: string; + sum_as_string: string; + } + | {} + ); + stats_bucket: { + count: number; + min: number | null; + max: number | null; + avg: number | null; + sum: number; + }; + string_stats: { + count: number; + min_length: number | null; + max_length: number | null; + avg_length: number | null; + entropy: number | null; + distribution: Record; + }; + sum: { + value: number | null; + value_as_string?: string; + }; + sum_bucket: { + value: number | null; + }; + terms: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: Array< + { + doc_count: number; + key: string | number; + } & SubAggregateOf + >; + }; + top_hits: { + hits: { + total: { + value: number; + relation: 'eq' | 'gte'; + }; + max_score: number | null; + hits: TAggregationContainer extends { top_hits: estypes.TopHitsAggregation } + ? HitsOf + : estypes.HitsMetadata; + }; + }; + top_metrics: { + top: Array<{ + sort: number[] | string[]; + metrics: Record< + TAggregationContainer extends Record }> + ? TKeys + : string, + string | number | null + >; + }>; + }; + weighted_avg: { value: number | null }; + value_count: { + value: number; + }; + // t_test: {} not defined +})[ValidAggregationKeysOf & AggregationTypeName]; + +type AggregateOfMap = { + [TAggregationName in keyof TAggregationMap]: TAggregationMap[TAggregationName] extends estypes.AggregationContainer + ? AggregateOf + : never; // using never means we effectively ignore optional keys, using {} creates a union type of { ... } | {} +}; + +type SubAggregateOf = TAggregationRequest extends { + aggs?: AggregationMap; +} + ? AggregateOfMap + : TAggregationRequest extends { aggregations?: AggregationMap } + ? AggregateOfMap + : {}; + +type SearchResponseOf< + TAggregationRequest extends TopLevelAggregationRequest, + TDocument +> = SubAggregateOf; + +// if aggregation response cannot be inferred, fall back to unknown +type WrapAggregationResponse = keyof T extends never + ? { aggregations?: unknown } + : { aggregations?: T }; + +export type InferSearchResponseOf< + TDocument = unknown, + TSearchRequest extends estypes.SearchRequest = estypes.SearchRequest, + TOptions extends { restTotalHitsAsInt?: boolean } = {} +> = Omit, 'aggregations' | 'hits'> & + (TSearchRequest['body'] extends TopLevelAggregationRequest + ? WrapAggregationResponse> + : { aggregations?: InvalidAggregationRequest }) & { + hits: Omit['hits'], 'total' | 'hits'> & + (TOptions['restTotalHitsAsInt'] extends true + ? { + total: number; + } + : { + total: { + value: number; + relation: 'eq' | 'gte'; + }; + }) & { hits: HitsOf }; + }; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 98b9b46fac48d..a333d86b27129 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -744,6 +744,7 @@ describe('getAll()', () => { }; unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, @@ -817,6 +818,7 @@ describe('getAll()', () => { ], }); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, @@ -877,6 +879,7 @@ describe('getAll()', () => { }; unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, @@ -949,6 +952,7 @@ describe('getBulk()', () => { ], }); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, @@ -1019,6 +1023,7 @@ describe('getBulk()', () => { ], }); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, @@ -1076,6 +1081,7 @@ describe('getBulk()', () => { ], }); scopedClusterClient.asInternalUser.search.mockResolvedValueOnce( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { '1': { doc_count: 6 }, diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 2e2b3e7a6d814..d8dcde2fab103 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -6,6 +6,7 @@ */ import Boom from '@hapi/boom'; +import type { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { omitBy, isUndefined } from 'lodash'; @@ -509,7 +510,7 @@ async function injectExtraFindData( scopedClusterClient: IScopedClusterClient, actionResults: ActionResult[] ): Promise { - const aggs: Record = {}; + const aggs: Record = {}; for (const actionResult of actionResults) { aggs[actionResult.id] = { filter: { @@ -555,6 +556,7 @@ async function injectExtraFindData( }); return actionResults.map((actionResult) => ({ ...actionResult, + // @ts-expect-error aggegation type is not specified referencedByCount: aggregationResult.aggregations[actionResult.id].doc_count, })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index 67ba7ffea10e8..f7b0e7de478d8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -93,8 +93,8 @@ async function executor( const err = find(result.items, 'index.error.reason'); if (err) { return wrapErr( - `${err.index.error!.reason}${ - err.index.error?.caused_by ? ` (${err.index.error?.caused_by?.reason})` : '' + `${err.index?.error?.reason}${ + err.index?.error?.caused_by ? ` (${err.index?.error?.caused_by?.reason})` : '' }`, actionId, logger diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts index a998fc7af0c99..e4611857ca279 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts @@ -13,6 +13,7 @@ describe('actions telemetry', () => { test('getTotalCount should replace first symbol . to __ for action types names', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { byActionTypeId: { @@ -116,6 +117,7 @@ Object { test('getInUseTotalCount', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( + // @ts-expect-error not full search response elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { refs: { diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.ts index 6973a7e8dcbd2..8d028b176a00a 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.ts @@ -53,21 +53,19 @@ export async function getTotalCount(esClient: ElasticsearchClient, kibanaIndex: }, }, }); - + // @ts-expect-error aggegation type is not specified + const aggs = searchResult.aggregations?.byActionTypeId.value?.types; return { - countTotal: Object.keys(searchResult.aggregations.byActionTypeId.value.types).reduce( - (total: number, key: string) => - parseInt(searchResult.aggregations.byActionTypeId.value.types[key], 0) + total, + countTotal: Object.keys(aggs).reduce( + (total: number, key: string) => parseInt(aggs[key], 0) + total, 0 ), - countByType: Object.keys(searchResult.aggregations.byActionTypeId.value.types).reduce( + countByType: Object.keys(aggs).reduce( // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: searchResult.aggregations.byActionTypeId.value.types[ - key - ], + [replaceFirstAndLastDotSymbols(key)]: aggs[key], }), {} ), @@ -161,9 +159,9 @@ export async function getInUseTotalCount( }, }); - const bulkFilter = Object.entries( - actionResults.aggregations.refs.actionRefIds.value.connectorIds - ).map(([key]) => ({ + // @ts-expect-error aggegation type is not specified + const aggs = actionResults.aggregations.refs.actionRefIds.value; + const bulkFilter = Object.entries(aggs.connectorIds).map(([key]) => ({ id: key, type: 'action', fields: ['id', 'actionTypeId'], @@ -179,7 +177,7 @@ export async function getInUseTotalCount( }, {} ); - return { countTotal: actionResults.aggregations.refs.actionRefIds.value.total, countByType }; + return { countTotal: aggs.total, countByType }; } function replaceFirstAndLastDotSymbols(strToReplace: string) { diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index f8a91e3a0a67a..c338bbc998c49 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -23,6 +23,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { __servicenow: { type: 'long' }, __jira: { type: 'long' }, __resilient: { type: 'long' }, + __teams: { type: 'long' }, }; export function createActionsUsageCollector( diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index 1b1075f4d7cf1..1b29191d9063e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -8,6 +8,7 @@ import Boom from '@hapi/boom'; import { omit, isEqual, map, uniq, pick, truncate, trim } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { estypes } from '@elastic/elasticsearch'; import { Logger, SavedObjectsClientContract, @@ -100,7 +101,7 @@ export interface FindOptions extends IndexType { defaultSearchOperator?: 'AND' | 'OR'; searchFields?: string[]; sortField?: string; - sortOrder?: string; + sortOrder?: estypes.SortOrder; hasReference?: { type: string; id: string; diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts index 3c9decdf7ba96..cce394d70ed6f 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts @@ -13,6 +13,7 @@ describe('alerts telemetry', () => { test('getTotalCountInUse should replace first "." symbol to "__" in alert types names', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { byAlertTypeId: { diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts index 93bed31ce7d50..46ac3e53895eb 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts @@ -246,50 +246,59 @@ export async function getTotalCountAggregations( }, }); - const totalAlertsCount = Object.keys(results.aggregations.byAlertTypeId.value.types).reduce( + const aggregations = results.aggregations as { + byAlertTypeId: { value: { types: Record } }; + throttleTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; + intervalTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; + connectorsAgg: { + connectors: { + value: { min: number; max: number; totalActionsCount: number; totalAlertsCount: number }; + }; + }; + }; + + const totalAlertsCount = Object.keys(aggregations.byAlertTypeId.value.types).reduce( (total: number, key: string) => - parseInt(results.aggregations.byAlertTypeId.value.types[key], 0) + total, + parseInt(aggregations.byAlertTypeId.value.types[key], 0) + total, 0 ); return { count_total: totalAlertsCount, - count_by_type: Object.keys(results.aggregations.byAlertTypeId.value.types).reduce( + count_by_type: Object.keys(aggregations.byAlertTypeId.value.types).reduce( // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: results.aggregations.byAlertTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.types[key], }), {} ), throttle_time: { - min: `${results.aggregations.throttleTime.value.min}s`, + min: `${aggregations.throttleTime.value.min}s`, avg: `${ - results.aggregations.throttleTime.value.totalCount > 0 - ? results.aggregations.throttleTime.value.totalSum / - results.aggregations.throttleTime.value.totalCount + aggregations.throttleTime.value.totalCount > 0 + ? aggregations.throttleTime.value.totalSum / aggregations.throttleTime.value.totalCount : 0 }s`, - max: `${results.aggregations.throttleTime.value.max}s`, + max: `${aggregations.throttleTime.value.max}s`, }, schedule_time: { - min: `${results.aggregations.intervalTime.value.min}s`, + min: `${aggregations.intervalTime.value.min}s`, avg: `${ - results.aggregations.intervalTime.value.totalCount > 0 - ? results.aggregations.intervalTime.value.totalSum / - results.aggregations.intervalTime.value.totalCount + aggregations.intervalTime.value.totalCount > 0 + ? aggregations.intervalTime.value.totalSum / aggregations.intervalTime.value.totalCount : 0 }s`, - max: `${results.aggregations.intervalTime.value.max}s`, + max: `${aggregations.intervalTime.value.max}s`, }, connectors_per_alert: { - min: results.aggregations.connectorsAgg.connectors.value.min, + min: aggregations.connectorsAgg.connectors.value.min, avg: totalAlertsCount > 0 - ? results.aggregations.connectorsAgg.connectors.value.totalActionsCount / totalAlertsCount + ? aggregations.connectorsAgg.connectors.value.totalActionsCount / totalAlertsCount : 0, - max: results.aggregations.connectorsAgg.connectors.value.max, + max: aggregations.connectorsAgg.connectors.value.max, }, }; } @@ -308,20 +317,23 @@ export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaIn }, }, }); + + const aggregations = searchResult.aggregations as { + byAlertTypeId: { value: { types: Record } }; + }; + return { - countTotal: Object.keys(searchResult.aggregations.byAlertTypeId.value.types).reduce( + countTotal: Object.keys(aggregations.byAlertTypeId.value.types).reduce( (total: number, key: string) => - parseInt(searchResult.aggregations.byAlertTypeId.value.types[key], 0) + total, + parseInt(aggregations.byAlertTypeId.value.types[key], 0) + total, 0 ), - countByType: Object.keys(searchResult.aggregations.byAlertTypeId.value.types).reduce( + countByType: Object.keys(aggregations.byAlertTypeId.value.types).reduce( // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: searchResult.aggregations.byAlertTypeId.value.types[ - key - ], + [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.types[key], }), {} ), diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts index 884120d3d03df..59aeb4854d9f0 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts @@ -16,6 +16,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { // Known alerts (searching the use of the alerts API `registerType`: // Built-in '__index-threshold': { type: 'long' }, + '__es-query': { type: 'long' }, // APM apm__error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention apm__transaction_error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention @@ -41,6 +42,10 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { xpack__uptime__alerts__monitorStatus: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__tls: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__durationAnomaly: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + // Maps + '__geo-containment': { type: 'long' }, + // ML + xpack_ml_anomaly_detection_alert: { type: 'long' }, }; export function createAlertsUsageCollector( diff --git a/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts new file mode 100644 index 0000000000000..fb7ef6d36ce25 --- /dev/null +++ b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint( + endpoint: string, + pathParams: Record = {} +) { + const [method, rawPathname] = endpoint.split(' '); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method: parseMethod(method), pathname }; +} + +export function parseMethod(method: string) { + const res = method.trim().toLowerCase() as Method; + + if (!['get', 'post', 'put', 'delete'].includes(res)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return res; +} diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts index 3316c74d52e38..4212e0430ff5f 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts @@ -45,10 +45,10 @@ describe('strictKeysRt', () => { { type: t.intersection([ t.type({ query: t.type({ bar: t.string }) }), - t.partial({ query: t.partial({ _debug: t.boolean }) }), + t.partial({ query: t.partial({ _inspect: t.boolean }) }), ]), - passes: [{ query: { bar: '', _debug: true } }], - fails: [{ query: { _debug: true } }], + passes: [{ query: { bar: '', _inspect: true } }], + fails: [{ query: { _inspect: true } }], }, ]; @@ -91,12 +91,12 @@ describe('strictKeysRt', () => { } as Record); const typeB = t.partial({ - query: t.partial({ _debug: jsonRt.pipe(t.boolean) }), + query: t.partial({ _inspect: jsonRt.pipe(t.boolean) }), }); const value = { query: { - _debug: 'true', + _inspect: 'true', filterNames: JSON.stringify(['host', 'agentName']), }, }; diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index b785fcc7dab08..7df6ca343426c 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -8,7 +8,7 @@ import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; -import { AppMountParameters, CoreStart, HttpSetup } from 'src/core/public'; +import { AppMountParameters, CoreStart } from 'src/core/public'; import { mockApmPluginContextValue } from '../context/apm_plugin/mock_apm_plugin_context'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; @@ -72,7 +72,7 @@ describe('renderApp', () => { embeddable, }; jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); - createCallApmApi((core.http as unknown) as HttpSetup); + createCallApmApi((core as unknown) as CoreStart); jest .spyOn(window.console, 'warn') diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 8ea4593bb89a7..787b15d0a5675 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -118,7 +118,7 @@ export const renderApp = ( ) => { const { element } = appMountParameters; - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 64600dd500bd5..bc14bc1531686 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -120,7 +120,7 @@ export const renderApp = ( // render APM feedback link in global help menu setHelpExtension(core); setReadonlyBadge(core); - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index 29f74b26d310c..fdfed6eb0d685 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -107,7 +107,11 @@ export function ErrorCountAlertTrigger(props: Props) { ]; const chartPreview = ( - + ); return ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 11aab788ec7f4..b4c78b54f329b 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -13,7 +13,6 @@ import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../typings/timeseries'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; @@ -116,9 +115,9 @@ export function TransactionDurationAlertTrigger(props: Props) { ] ); - const maxY = getMaxY([ - { data: data ?? [] } as TimeSeries<{ x: number; y: number | null }>, - ]); + const latencyChartPreview = data?.latencyChartPreview ?? []; + + const maxY = getMaxY([{ data: latencyChartPreview }]); const formatter = getDurationFormatter(maxY); const yTickFormat = getResponseTimeTickFormatter(formatter); @@ -127,7 +126,7 @@ export function TransactionDurationAlertTrigger(props: Props) { const chartPreview = ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index de30af4a4707f..c6f9c4efd98b6 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -132,7 +132,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { const chartPreview = ( asPercent(d, 1)} threshold={thresholdAsPercent} /> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx index 6c94b895f6924..db5932a96fb12 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -35,7 +35,7 @@ export function BreakdownSeries({ ? EUI_CHARTS_THEME_DARK : EUI_CHARTS_THEME_LIGHT; - const { data, status } = useBreakdowns({ + const { breakdowns, status } = useBreakdowns({ field, value, percentileRange, @@ -49,7 +49,7 @@ export function BreakdownSeries({ // so don't user that here return ( <> - {data?.map(({ data: seriesData, name }, sortIndex) => ( + {breakdowns.map(({ data: seriesData, name }, sortIndex) => (
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts index 5af7f0682db19..e21aaa08c432d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -17,12 +17,10 @@ interface Props { export const useBreakdowns = ({ percentileRange, field, value }: Props) => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end, searchTerm } = urlParams; - const { min: minP, max: maxP } = percentileRange ?? {}; - return useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (start && end && field && value) { return callApmApi({ @@ -47,4 +45,6 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => { }, [end, start, uiFilters, field, value, minP, maxP, searchTerm] ); + + return { breakdowns: data?.pageLoadDistBreakdown ?? [], status }; }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index e3e2a979c48d3..d04bcb79a53e1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -38,6 +38,7 @@ export function MainFilters() { [start, end] ); + const rumServiceNames = data?.rumServices ?? []; const { isSmall } = useBreakPoints(); // on mobile we want it to take full width @@ -48,7 +49,7 @@ export function MainFilters() { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.test.tsx index 4b925e914bfa5..f286f963b4fa0 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.test.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.test.tsx @@ -36,7 +36,7 @@ mockEmbeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ }), })); -const mockCore: () => [any] = () => { +const mockCore: () => any[] = () => { const core = { embeddable: mockEmbeddable, }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts index c40f6ba2b8850..8ae4c9dc0e01d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts @@ -68,7 +68,7 @@ export function useLocalUIFilters({ }); }; - const { data = getInitialData(filterNames), status } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (shouldFetch && urlParams.start && urlParams.end) { return callApmApi({ @@ -96,7 +96,8 @@ export function useLocalUIFilters({ ] ); - const filters = data.map((filter) => ({ + const localUiFilters = data?.localUiFilters ?? getInitialData(filterNames); + const filters = localUiFilters.map((filter) => ({ ...filter, value: values[filter.name] || [], })); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts index 5b448871804eb..f932cec3cacb6 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts @@ -11,9 +11,9 @@ import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plug import { FetchOptions } from '../../../../../common/fetch_options'; export function useCallApi() { - const { http } = useApmPluginContext().core; + const { core } = useApmPluginContext(); return useMemo(() => { - return (options: FetchOptions) => callApi(http, options); - }, [http]); + return (options: FetchOptions) => callApi(core, options); + }, [core]); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index d754710dc84fa..ac1846155569a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -6,7 +6,7 @@ */ import cytoscape from 'cytoscape'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import React, { ComponentType } from 'react'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; @@ -21,19 +21,21 @@ export default { component: Popover, decorators: [ (Story: ComponentType) => { - const httpMock = ({ - get: async () => ({ - avgCpuUsage: 0.32809666568309237, - avgErrorRate: 0.556068173242986, - avgMemoryUsage: 0.5504868173242986, - transactionStats: { - avgRequestsPerMinute: 164.47222031860858, - avgTransactionDuration: 61634.38905590272, - }, - }), - } as unknown) as HttpSetup; + const coreMock = ({ + http: { + get: async () => ({ + avgCpuUsage: 0.32809666568309237, + avgErrorRate: 0.556068173242986, + avgMemoryUsage: 0.5504868173242986, + transactionStats: { + avgRequestsPerMinute: 164.47222031860858, + avgTransactionDuration: 61634.38905590272, + }, + }), + }, + } as unknown) as CoreStart; - createCallApmApi(httpMock); + createCallApmApi(coreMock); return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx index e762f517ce1b5..71355a84d28d4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -33,7 +33,7 @@ interface Props { } export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + const { data: serviceNamesData, status: serviceNamesStatus } = useFetcher( (callApmApi) => { return callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration/services', @@ -43,8 +43,9 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { [], { preservePreviousData: false } ); + const serviceNames = serviceNamesData?.serviceNames ?? []; - const { data: environments = [], status: environmentStatus } = useFetcher( + const { data: environmentsData, status: environmentsStatus } = useFetcher( (callApmApi) => { if (newConfig.service.name) { return callApmApi({ @@ -59,6 +60,8 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { { preservePreviousData: false } ); + const environments = environmentsData?.environments ?? []; + const { status: agentNameStatus } = useFetcher( async (callApmApi) => { const serviceName = newConfig.service.name; @@ -153,11 +156,11 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { 'xpack.apm.agentConfig.servicePage.environment.fieldLabel', { defaultMessage: 'Service environment' } )} - isLoading={environmentStatus === FETCH_STATUS.LOADING} + isLoading={environmentsStatus === FETCH_STATUS.LOADING} options={environmentOptions} value={newConfig.service.environment} disabled={ - !newConfig.service.name || environmentStatus === FETCH_STATUS.LOADING + !newConfig.service.name || environmentsStatus === FETCH_STATUS.LOADING } onChange={(e) => { e.preventDefault(); diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 4d2754a677bf7..cd5fa5db89a31 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -7,7 +7,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; import { AgentConfiguration } from '../../../../../../common/agent_configuration/configuration_types'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; @@ -23,10 +23,10 @@ storiesOf( module ) .addDecorator((storyFn) => { - const httpMock = {}; + const coreMock = ({} as unknown) as CoreStart; // mock - createCallApmApi((httpMock as unknown) as HttpSetup); + createCallApmApi(coreMock); const contextMock = { core: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 081a3dbc907c5..3e3bc892e6518 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -16,7 +16,7 @@ import { } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { config: Config; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx index bef0dfc22280c..c098be41968dd 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -32,15 +32,19 @@ import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; import { ConfirmDeleteModal } from './ConfirmDeleteModal'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { status: FETCH_STATUS; - data: Config[]; + configurations: Config[]; refetch: () => void; } -export function AgentConfigurationList({ status, data, refetch }: Props) { +export function AgentConfigurationList({ + status, + configurations, + refetch, +}: Props) { const { core } = useApmPluginContext(); const canSave = core.application.capabilities.apm.save; const { basePath } = core.http; @@ -113,7 +117,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { return failurePrompt; } - if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + if (status === FETCH_STATUS.SUCCESS && isEmpty(configurations)) { return emptyStatePrompt; } @@ -231,7 +235,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { } columns={columns} - items={data} + items={configurations} initialSortField="service.name" initialSortDirection="asc" initialPageSize={20} 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 8aa0c35f36717..3225951fd6c70 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 @@ -25,8 +25,10 @@ import { useFetcher } from '../../../../hooks/use_fetcher'; import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; import { AgentConfigurationList } from './List'; +const INITIAL_DATA = { configurations: [] }; + export function AgentConfigurations() { - const { refetch, data = [], status } = useFetcher( + const { refetch, data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration' }), [], @@ -36,7 +38,7 @@ export function AgentConfigurations() { useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); - const hasConfigurations = !isEmpty(data); + const hasConfigurations = !isEmpty(data.configurations); return ( <> @@ -72,7 +74,11 @@ export function AgentConfigurations() { - + ); 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 9722c99990e3f..9d2b4bba22afb 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 @@ -24,7 +24,10 @@ import React, { useEffect, useState } from 'react'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { clearCache } from '../../../../services/rest/callApi'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; +import { + APIReturnType, + callApmApi, +} from '../../../../services/rest/createCallApmApi'; const APM_INDEX_LABELS = [ { @@ -84,8 +87,10 @@ async function saveApmIndices({ clearCache(); } +type ApiResponse = APIReturnType<`GET /api/apm/settings/apm-index-settings`>; + // avoid infinite loop by initializing the state outside the component -const INITIAL_STATE = [] as []; +const INITIAL_STATE: ApiResponse = { apmIndexSettings: [] }; export function ApmIndices() { const { core } = useApmPluginContext(); @@ -108,7 +113,7 @@ export function ApmIndices() { useEffect(() => { setApmIndices( - data.reduce( + data.apmIndexSettings.reduce( (acc, { configurationName, savedValue }) => ({ ...acc, [configurationName]: savedValue, @@ -190,7 +195,7 @@ export function ApmIndices() { {APM_INDEX_LABELS.map(({ configurationName, label }) => { - const matchedConfiguration = data.find( + const matchedConfiguration = data.apmIndexSettings.find( ({ configurationName: configName }) => configName === configurationName ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 0dbc8f6235342..77835afef863a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -24,20 +24,12 @@ import { } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CreateEditCustomLinkFlyout/saveCustomLink'; -const data = [ - { - id: '1', - label: 'label 1', - url: 'url 1', - 'service.name': 'opbeans-java', - }, - { - id: '2', - label: 'label 2', - url: 'url 2', - 'transaction.type': 'request', - }, -]; +const data = { + customLinks: [ + { id: '1', label: 'label 1', url: 'url 1', 'service.name': 'opbeans-java' }, + { id: '2', label: 'label 2', url: 'url 2', 'transaction.type': 'request' }, + ], +}; function getMockAPMContext({ canSave }: { canSave: boolean }) { return ({ @@ -69,7 +61,7 @@ describe('CustomLink', () => { describe('empty prompt', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -290,7 +282,7 @@ describe('CustomLink', () => { describe('invalid license', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index 4b4bc2e8feeab..49fa3eab47862 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -35,7 +35,7 @@ export function CustomLinkOverview() { CustomLink | undefined >(); - const { data: customLinks = [], status, refetch } = useFetcher( + const { data, status, refetch } = useFetcher( async (callApmApi) => { if (hasValidLicense) { return callApmApi({ @@ -46,6 +46,8 @@ export function CustomLinkOverview() { [hasValidLicense] ); + const customLinks = data?.customLinks ?? []; + useEffect(() => { if (customLinkSelected) { setIsFlyoutOpen(true); diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 6a11f862994e2..bf9062418313a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -21,6 +21,7 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { ML_ERRORS } from '../../../../../common/anomaly_detection'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; @@ -33,6 +34,10 @@ interface Props { onCreateJobSuccess: () => void; onCancel: () => void; } + +type ApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/environments'>; +const INITIAL_DATA: ApiResponse = { environments: [] }; + export function AddEnvironments({ currentEnvironments, onCreateJobSuccess, @@ -42,7 +47,7 @@ export function AddEnvironments({ const { anomalyDetectionJobsRefetch } = useAnomalyDetectionJobsContext(); const canCreateJob = !!application.capabilities.ml.canCreateJob; const { toasts } = notifications; - const { data = [], status } = useFetcher( + const { data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: `GET /api/apm/settings/anomaly-detection/environments`, @@ -51,7 +56,7 @@ export function AddEnvironments({ { preservePreviousData: false } ); - const environmentOptions = data.map((env) => ({ + const environmentOptions = data.environments.map((env) => ({ label: getEnvironmentLabel(env), value: env, disabled: currentEnvironments.includes(env), diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx index 66fb72975acea..f31354bc7aa3c 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx @@ -49,10 +49,10 @@ const Culprit = euiStyled.div` font-family: ${fontFamilyCode}; `; -type ErrorGroupListAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/errors'>; +type ErrorGroupItem = APIReturnType<'GET /api/apm/services/{serviceName}/errors'>['errorGroups'][0]; interface Props { - items: ErrorGroupListAPIResponse; + items: ErrorGroupItem[]; serviceName: string; } @@ -128,7 +128,7 @@ function ErrorGroupList({ items, serviceName }: Props) { field: 'message', sortable: false, width: '50%', - render: (message: string, item: ErrorGroupListAPIResponse[0]) => { + render: (message: string, item: ErrorGroupItem) => { return ( 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 f4870439fe478..fc218f3ba6df3 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 @@ -39,7 +39,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { urlParams: { kuery, start, end }, } = useUrlParams(); - const { data: items = [] } = useFetcher( + const { data } = useFetcher( (callApmApi) => { if (!start || !end) { return undefined; @@ -61,6 +61,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { [kuery, serviceName, start, end] ); + const items = data?.serviceNodes ?? []; const columns: Array> = [ { name: ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index f6ffec46f9f51..b30faac7a65af 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -91,7 +91,7 @@ describe('ServiceOverview', () => { isAggregationAccurate: true, }, 'GET /api/apm/services/{serviceName}/dependencies': [], - 'GET /api/apm/services/{serviceName}/service_overview_instances': [], + 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics': [], }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index a4647bc148b1e..4ff42b151dc8e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -164,7 +164,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { }, ]; - const { data = [], status } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (!start || !end) { return; @@ -188,8 +188,10 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { [start, end, serviceName, environment] ); + const serviceDependencies = data?.serviceDependencies ?? []; + // need top-level sortable fields for the managed table - const items = data.map((item) => ({ + const items = serviceDependencies.map((item) => ({ ...item, errorRateValue: item.errorRate.value, latencyValue: item.latency.value, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 435def8bb9a91..13322b094c65e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -6,11 +6,18 @@ */ import { EuiFlexItem, EuiPanel } from '@elastic/eui'; -import React from 'react'; +import { orderBy } from 'lodash'; +import React, { useState } from 'react'; +import uuid from 'uuid'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useFetcher } from '../../../hooks/use_fetcher'; -import { ServiceOverviewInstancesTable } from './service_overview_instances_table'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; +import { + ServiceOverviewInstancesTable, + TableOptions, +} from './service_overview_instances_table'; // We're hiding this chart until these issues are resolved in the 7.13 timeframe: // @@ -24,17 +31,77 @@ interface ServiceOverviewInstancesChartAndTableProps { serviceName: string; } +export interface PrimaryStatsServiceInstanceItem { + serviceNodeName: string; + errorRate: number; + throughput: number; + latency: number; + cpuUsage: number; + memoryUsage: number; +} + +const INITIAL_STATE_PRIMARY_STATS = { + primaryStatsItems: [] as PrimaryStatsServiceInstanceItem[], + primaryStatsRequestId: undefined, + primaryStatsItemCount: 0, +}; + +type ApiResponseComparisonStats = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; + +const INITIAL_STATE_COMPARISON_STATISTICS: ApiResponseComparisonStats = { + currentPeriod: {}, + previousPeriod: {}, +}; + +export type SortField = + | 'serviceNodeName' + | 'latency' + | 'throughput' + | 'errorRate' + | 'cpuUsage' + | 'memoryUsage'; + +export type SortDirection = 'asc' | 'desc'; +export const PAGE_SIZE = 5; +const DEFAULT_SORT = { + direction: 'desc' as const, + field: 'throughput' as const, +}; + export function ServiceOverviewInstancesChartAndTable({ chartHeight, serviceName, }: ServiceOverviewInstancesChartAndTableProps) { const { transactionType } = useApmServiceContext(); + const [tableOptions, setTableOptions] = useState({ + pageIndex: 0, + sort: DEFAULT_SORT, + }); + + const { pageIndex, sort } = tableOptions; + const { direction, field } = sort; const { - urlParams: { environment, kuery, latencyAggregationType, start, end }, + urlParams: { + environment, + kuery, + latencyAggregationType, + start, + end, + comparisonType, + }, } = useUrlParams(); - const { data = [], status } = useFetcher( + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + start, + end, + comparisonType, + }); + + const { + data: primaryStatsData = INITIAL_STATE_PRIMARY_STATS, + status: primaryStatsStatus, + } = useFetcher( (callApmApi) => { if (!start || !end || !transactionType || !latencyAggregationType) { return; @@ -42,7 +109,7 @@ export function ServiceOverviewInstancesChartAndTable({ return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances', + 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', params: { path: { serviceName, @@ -54,11 +121,32 @@ export function ServiceOverviewInstancesChartAndTable({ start, end, transactionType, - numBuckets: 20, }, }, + }).then((response) => { + const primaryStatsItems = orderBy( + // need top-level sortable fields for the managed table + response.serviceInstances.map((item) => ({ + ...item, + latency: item.latency ?? 0, + throughput: item.throughput ?? 0, + errorRate: item.errorRate ?? 0, + cpuUsage: item.cpuUsage ?? 0, + memoryUsage: item.memoryUsage ?? 0, + })), + field, + direction + ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); + + return { + primaryStatsRequestId: uuid(), + primaryStatsItems, + primaryStatsItemCount: response.serviceInstances.length, + }; }); }, + // comparisonType is listed as dependency even thought it is not used. This is needed to trigger the comparison api when it is changed. + // eslint-disable-next-line react-hooks/exhaustive-deps [ environment, kuery, @@ -67,24 +155,97 @@ export function ServiceOverviewInstancesChartAndTable({ end, serviceName, transactionType, + pageIndex, + field, + direction, + comparisonType, ] ); + const { + primaryStatsItems, + primaryStatsRequestId, + primaryStatsItemCount, + } = primaryStatsData; + + const { + data: comparisonStatsData = INITIAL_STATE_COMPARISON_STATISTICS, + status: comparisonStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if ( + !start || + !end || + !transactionType || + !latencyAggregationType || + !primaryStatsItemCount + ) { + return; + } + + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', + params: { + path: { + serviceName, + }, + query: { + environment, + kuery, + latencyAggregationType, + start, + end, + numBuckets: 20, + transactionType, + serviceNodeIds: JSON.stringify( + primaryStatsItems.map((item) => item.serviceNodeName) + ), + comparisonStart, + comparisonEnd, + }, + }, + }); + }, + // only fetches comparison statistics when requestId is invalidated by primary statistics api call + // eslint-disable-next-line react-hooks/exhaustive-deps + [primaryStatsRequestId], + { preservePreviousData: false } + ); + return ( <> {/* */} { + setTableOptions({ + pageIndex: newTableOptions.page?.index ?? 0, + sort: newTableOptions.sort + ? { + field: newTableOptions.sort.field as SortField, + direction: newTableOptions.sort.direction, + } + : DEFAULT_SORT, + }); + }} /> 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 new file mode 100644 index 0000000000000..b88172a162063 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; +import { isJavaAgentName } from '../../../../../common/agent_name'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { px, unit } from '../../../../style/variables'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; +import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink'; +import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; +import { getLatencyColumnLabel } from '../get_latency_column_label'; +import { PrimaryStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table'; + +type ServiceInstanceComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; + +export function getColumns({ + serviceName, + agentName, + latencyAggregationType, + comparisonStatsData, + comparisonEnabled, +}: { + serviceName: string; + agentName?: string; + latencyAggregationType?: LatencyAggregationType; + comparisonStatsData?: ServiceInstanceComparisonStatistics; + comparisonEnabled?: boolean; +}): Array> { + return [ + { + field: 'serviceNodeName', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnNodeName', + { defaultMessage: 'Node name' } + ), + render: (_, item) => { + const { serviceNodeName } = item; + const isMissingServiceNodeName = + serviceNodeName === SERVICE_NODE_NAME_MISSING; + const text = isMissingServiceNodeName + ? UNIDENTIFIED_SERVICE_NODES_LABEL + : serviceNodeName; + + const link = isJavaAgentName(agentName) ? ( + + {text} + + ) : ( + ({ + ...query, + kuery: isMissingServiceNodeName + ? `NOT (service.node.name:*)` + : `service.node.name:"${item.serviceNodeName}"`, + })} + > + {text} + + ); + + return ; + }, + sortable: true, + }, + { + field: 'latencyValue', + name: getLatencyColumnLabel(latencyAggregationType), + width: px(unit * 10), + render: (_, { serviceNodeName, latency }) => { + const currentPeriodTimestamp = + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.latency; + const previousPeriodTimestamp = + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.latency; + return ( + + ); + }, + sortable: true, + }, + { + field: 'throughputValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnThroughput', + { defaultMessage: 'Throughput' } + ), + width: px(unit * 10), + render: (_, { serviceNodeName, throughput }) => { + const currentPeriodTimestamp = + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.throughput; + const previousPeriodTimestamp = + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.throughput; + return ( + + ); + }, + sortable: true, + }, + { + field: 'errorRateValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnErrorRate', + { defaultMessage: 'Error rate' } + ), + width: px(unit * 8), + render: (_, { serviceNodeName, errorRate }) => { + const currentPeriodTimestamp = + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.errorRate; + const previousPeriodTimestamp = + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.errorRate; + return ( + + ); + }, + sortable: true, + }, + { + field: 'cpuUsageValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnCpuUsage', + { defaultMessage: 'CPU usage (avg.)' } + ), + width: px(unit * 8), + render: (_, { serviceNodeName, cpuUsage }) => { + const currentPeriodTimestamp = + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.cpuUsage; + const previousPeriodTimestamp = + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.cpuUsage; + return ( + + ); + }, + sortable: true, + }, + { + field: 'memoryUsageValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage', + { defaultMessage: 'Memory usage (avg.)' } + ), + width: px(unit * 9), + render: (_, { serviceNodeName, memoryUsage }) => { + const currentPeriodTimestamp = + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.memoryUsage; + const previousPeriodTimestamp = + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.memoryUsage; + return ( + + ); + }, + sortable: true, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index 83ad506e8659b..28654acbefa46 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -6,208 +6,82 @@ */ import { - EuiBasicTableColumn, + EuiBasicTable, EuiFlexGroup, EuiFlexItem, - EuiInMemoryTable, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { ValuesType } from 'utility-types'; -import { isJavaAgentName } from '../../../../../common/agent_name'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; -import { - asMillisecondDuration, - asPercent, - asTransactionRate, -} from '../../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_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 { px, unit } from '../../../../style/variables'; -import { SparkPlot } from '../../../shared/charts/spark_plot'; -import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink'; -import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; -import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; -import { getLatencyColumnLabel } from '../get_latency_column_label'; +import { + PAGE_SIZE, + PrimaryStatsServiceInstanceItem, + SortDirection, + SortField, +} from '../service_overview_instances_chart_and_table'; import { ServiceOverviewTableContainer } from '../service_overview_table_container'; +import { getColumns } from './get_columns'; + +type ServiceInstanceComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; -type ServiceInstanceItem = ValuesType< - APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'> ->; +export interface TableOptions { + pageIndex: number; + sort: { + direction: SortDirection; + field: SortField; + }; +} interface Props { - items?: ServiceInstanceItem[]; + primaryStatsItems: PrimaryStatsServiceInstanceItem[]; serviceName: string; - status: FETCH_STATUS; + primaryStatsStatus: FETCH_STATUS; + primaryStatsItemCount: number; + tableOptions: TableOptions; + onChangeTableOptions: (newTableOptions: { + page?: { index: number }; + sort?: { field: string; direction: SortDirection }; + }) => void; + comparisonStatsData?: ServiceInstanceComparisonStatistics; + isLoading: boolean; } - export function ServiceOverviewInstancesTable({ - items = [], + primaryStatsItems = [], + primaryStatsItemCount, serviceName, - status, + primaryStatsStatus: status, + tableOptions, + onChangeTableOptions, + comparisonStatsData: comparisonStatsData, + isLoading, }: Props) { const { agentName } = useApmServiceContext(); const { - urlParams: { latencyAggregationType }, + urlParams: { latencyAggregationType, comparisonEnabled }, } = useUrlParams(); - const columns: Array> = [ - { - field: 'name', - name: i18n.translate( - 'xpack.apm.serviceOverview.instancesTableColumnNodeName', - { - defaultMessage: 'Node name', - } - ), - render: (_, item) => { - const { serviceNodeName } = item; - const isMissingServiceNodeName = - serviceNodeName === SERVICE_NODE_NAME_MISSING; - const text = isMissingServiceNodeName - ? UNIDENTIFIED_SERVICE_NODES_LABEL - : serviceNodeName; - - const link = isJavaAgentName(agentName) ? ( - - {text} - - ) : ( - ({ - ...query, - kuery: isMissingServiceNodeName - ? `NOT (service.node.name:*)` - : `service.node.name:"${item.serviceNodeName}"`, - })} - > - {text} - - ); + const { pageIndex, sort } = tableOptions; + const { direction, field } = sort; - return ; - }, - sortable: true, - }, - { - field: 'latencyValue', - name: getLatencyColumnLabel(latencyAggregationType), - width: px(unit * 10), - render: (_, { latency }) => { - return ( - - ); - }, - sortable: true, - }, - { - field: 'throughputValue', - name: i18n.translate( - 'xpack.apm.serviceOverview.instancesTableColumnThroughput', - { defaultMessage: 'Throughput' } - ), - width: px(unit * 10), - render: (_, { throughput }) => { - return ( - - ); - }, - sortable: true, - }, - { - field: 'errorRateValue', - name: i18n.translate( - 'xpack.apm.serviceOverview.instancesTableColumnErrorRate', - { - defaultMessage: 'Error rate', - } - ), - width: px(unit * 8), - render: (_, { errorRate }) => { - return ( - - ); - }, - sortable: true, - }, - { - field: 'cpuUsageValue', - name: i18n.translate( - 'xpack.apm.serviceOverview.instancesTableColumnCpuUsage', - { - defaultMessage: 'CPU usage (avg.)', - } - ), - width: px(unit * 8), - render: (_, { cpuUsage }) => { - return ( - - ); - }, - sortable: true, - }, - { - field: 'memoryUsageValue', - name: i18n.translate( - 'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage', - { - defaultMessage: 'Memory usage (avg.)', - } - ), - width: px(unit * 9), - render: (_, { memoryUsage }) => { - return ( - - ); - }, - sortable: true, - }, - ]; + const columns = getColumns({ + agentName, + serviceName, + latencyAggregationType, + comparisonStatsData, + comparisonEnabled, + }); - // need top-level sortable fields for the managed table - const tableItems = items.map((item) => ({ - ...item, - latencyValue: item.latency?.value ?? 0, - throughputValue: item.throughput?.value ?? 0, - errorRateValue: item.errorRate?.value ?? 0, - cpuUsageValue: item.cpuUsage?.value ?? 0, - memoryUsageValue: item.memoryUsage?.value ?? 0, - })); - - const isLoading = status === FETCH_STATUS.LOADING; + const pagination = { + pageIndex, + pageSize: PAGE_SIZE, + totalItemCount: primaryStatsItemCount, + hidePerPageOptions: true, + }; return ( @@ -223,24 +97,15 @@ export function ServiceOverviewInstancesTable({ - diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 02f60eab2cb88..121b96b0361b2 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { orderBy } from 'lodash'; import React, { useState } from 'react'; import uuid from 'uuid'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; @@ -28,8 +29,9 @@ interface Props { serviceName: string; } +type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; const INITIAL_STATE = { - transactionGroups: [], + transactionGroups: [] as ApiResponse['transactionGroups'], isAggregationAccurate: true, requestId: '', transactionGroupsTotalItems: 0, 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 23adbb23b2322..94391b5b2fb06 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 @@ -19,6 +19,7 @@ import { } from '../../../../common/profiling'; 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'; @@ -28,6 +29,9 @@ interface ServiceProfilingProps { environment?: string; } +type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/profiling/timeline'>; +const DEFAULT_DATA: ApiResponse = { profilingTimeline: [] }; + export function ServiceProfiling({ serviceName, environment, @@ -36,7 +40,7 @@ export function ServiceProfiling({ urlParams: { kuery, start, end }, } = useUrlParams(); - const { data = [] } = useFetcher( + const { data = DEFAULT_DATA } = useFetcher( (callApmApi) => { if (!start || !end) { return; @@ -58,14 +62,16 @@ export function ServiceProfiling({ [kuery, start, end, serviceName, environment] ); + const { profilingTimeline } = data; + const [valueType, setValueType] = useState(); useEffect(() => { - if (!data.length) { + if (!profilingTimeline.length) { return; } - const availableValueTypes = data.reduce((set, point) => { + const availableValueTypes = profilingTimeline.reduce((set, point) => { (Object.keys(point.valueTypes).filter( (type) => type !== 'unknown' ) as ProfilingValueType[]) @@ -80,7 +86,7 @@ export function ServiceProfiling({ if (!valueType || !availableValueTypes.has(valueType)) { setValueType(Array.from(availableValueTypes)[0]); } - }, [data, valueType]); + }, [profilingTimeline, valueType]); return ( <> @@ -103,7 +109,7 @@ export function ServiceProfiling({ { setValueType(type); }} 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 index 3cd858aceaa90..4bc9764b704b0 100644 --- 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 @@ -8,7 +8,7 @@ import { EuiTitle } from '@elastic/eui'; import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { HttpSetup } from '../../../../../../../src/core/public'; +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'; @@ -20,7 +20,7 @@ export default { component: ApmHeader, decorators: [ (Story: ComponentType) => { - createCallApmApi(({} as unknown) as HttpSetup); + createCallApmApi(({} as unknown) as CoreStart); return ( diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx index 6f2910a2a5ef7..a624c220a0e4c 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx @@ -9,7 +9,6 @@ import { act, fireEvent, render } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CustomLinkMenuSection } from '.'; -import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import * as useFetcher from '../../../../hooks/use_fetcher'; @@ -40,7 +39,7 @@ const transaction = ({ describe('Custom links', () => { it('shows empty message when no custom link is available', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -58,7 +57,7 @@ describe('Custom links', () => { it('shows loading while custom links are fetched', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.LOADING, refetch: jest.fn(), }); @@ -71,12 +70,14 @@ describe('Custom links', () => { }); it('shows first 3 custom links available', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const customLinks = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ data: customLinks, @@ -93,15 +94,17 @@ describe('Custom links', () => { }); it('clicks "show all" and "show fewer"', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const data = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: customLinks, + data, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -125,7 +128,7 @@ describe('Custom links', () => { describe('create custom link buttons', () => { it('shows create button below empty message', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -140,15 +143,17 @@ describe('Custom links', () => { }); it('shows create button besides the title', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const data = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: customLinks, + data, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx index 7d2e4a13278ec..cbbf34c78c4af 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx @@ -58,7 +58,7 @@ export function CustomLinkMenuSection({ [transaction] ); - const { data: customLinks = [], status, refetch } = useFetcher( + const { data, status, refetch } = useFetcher( (callApmApi) => callApmApi({ isCachable: false, @@ -68,6 +68,8 @@ export function CustomLinkMenuSection({ [filters] ); + const customLinks = data?.customLinks ?? []; + return ( <> {isCreateEditFlyoutOpen && ( diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts index b0ac35cc3667a..b8d67f71a9baa 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts @@ -7,7 +7,7 @@ import { onBrushEnd, isTimeseriesEmpty } from './helper'; import { History } from 'history'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; describe('Chart helper', () => { describe('onBrushEnd', () => { @@ -52,7 +52,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns true when y coordinate is null', () => { @@ -63,7 +63,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns true when y coordinate is undefined', () => { @@ -74,7 +74,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns false when at least one coordinate is filled', () => { @@ -91,7 +91,7 @@ describe('Chart helper', () => { type: 'line', color: 'green', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeFalsy(); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts index 3b93cb1f402e8..d94f2ce8f5c5d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts @@ -7,7 +7,7 @@ import { XYBrushArea } from '@elastic/charts'; import { History } from 'history'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { fromQuery, toQuery } from '../../Links/url_helpers'; export const onBrushEnd = ({ @@ -36,15 +36,12 @@ export const onBrushEnd = ({ } }; -export function isTimeseriesEmpty(timeseries?: TimeSeries[]) { +export function isTimeseriesEmpty(timeseries?: Array>) { return ( !timeseries || timeseries .map((serie) => serie.data) .flat() - .every( - ({ y }: { x?: number | null; y?: number | null }) => - y === null || y === undefined - ) + .every(({ y }: Coordinate) => y === null || y === undefined) ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index aa353b40d464a..5bcf0d161653e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -23,13 +23,13 @@ import { } from '../../../../../common/utils/formatters'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; import { ChartContainer } from '../chart_container'; import { getResponseTimeTickFormatter } from '../transaction_charts/helper'; interface InstancesLatencyDistributionChartProps { height: number; - items?: APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>; + items?: PrimaryStatsServiceInstanceItem[]; status: FETCH_STATUS; } @@ -44,15 +44,11 @@ export function InstancesLatencyDistributionChart({ const chartTheme = { ...useChartTheme(), bubbleSeriesStyle: { - point: { - strokeWidth: 0, - fill: theme.eui.euiColorVis1, - radius: 4, - }, + point: { strokeWidth: 0, fill: theme.eui.euiColorVis1, radius: 4 }, }, }; - const maxLatency = Math.max(...items.map((item) => item.latency?.value ?? 0)); + const maxLatency = Math.max(...items.map((item) => item.latency ?? 0)); const latencyFormatter = getDurationFormatter(maxLatency); return ( @@ -79,9 +75,9 @@ export function InstancesLatencyDistributionChart({ 'xpack.apm.instancesLatencyDistributionChartLegend', { defaultMessage: 'Instances' } )} - xAccessor={(item) => item.throughput?.value} + xAccessor={(item) => item.throughput} xScaleType={ScaleType.Linear} - yAccessors={[(item) => item.latency?.value]} + yAccessors={[(item) => item.latency]} yScaleType={ScaleType.Linear} /> >; /** * Formatter for y-axis tick values */ @@ -85,12 +89,10 @@ export function TimeseriesChart({ const max = Math.max(...xValues); const xFormatter = niceTimeFormatter([min, max]); - const isEmpty = isTimeseriesEmpty(timeseries); - const annotationColor = theme.eui.euiColorSecondary; - const allSeries = [...timeseries, ...(anomalyTimeseries?.boundaries ?? [])]; + const xDomain = isEmpty ? { min: 0, max: 1 } : { min, max }; return ( @@ -111,7 +113,7 @@ export function TimeseriesChart({ showLegend showLegendExtra legendPosition={Position.Bottom} - xDomain={{ min, max }} + xDomain={xDomain} onLegendItemClick={(legend) => { if (onToggleLegend) { onToggleLegend(legend); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index f55389ec2d5f7..23016cc5dd8e9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -28,7 +28,7 @@ import { asAbsoluteDateTime, asPercent, } from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; @@ -42,7 +42,7 @@ interface Props { fetchStatus: FETCH_STATUS; height?: number; showAnnotations: boolean; - timeseries?: TimeSeries[]; + timeseries?: Array>; } export function TransactionBreakdownChartContents({ diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx index 6c46580f4738e..31d18b7a9709d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx @@ -6,14 +6,14 @@ */ import { isFiniteNumber } from '../../../../../common/utils/is_finite_number'; -import { APMChartSpec, Coordinate } from '../../../../../typings/timeseries'; +import { Coordinate } from '../../../../../typings/timeseries'; import { TimeFormatter } from '../../../../../common/utils/formatters'; export function getResponseTimeTickFormatter(formatter: TimeFormatter) { return (t: number) => formatter(t).formatted; } -export function getMaxY(specs?: Array>) { +export function getMaxY(specs?: Array<{ data: Coordinate[] }>) { const values = specs ?.flatMap((spec) => spec.data) .map((coord) => coord.y) 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 2bd3fef8c0e88..1018b9eca2119 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -7,14 +7,21 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; +import { enableInspectEsQueries } from '../../../../observability/public'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { px, unit } from '../../style/variables'; import { DatePicker } from './DatePicker'; import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; import { useBreakPoints } from '../../hooks/use_break_points'; +import { useKibanaUrl } from '../../hooks/useKibanaUrl'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -const SearchBarFlexGroup = euiStyled(EuiFlexGroup)` +const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` margin: ${({ theme }) => `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; `; @@ -29,6 +36,52 @@ function getRowDirection(showColumn: boolean) { return showColumn ? 'column' : 'row'; } +function DebugQueryCallout() { + const { uiSettings } = useApmPluginContext().core; + const advancedSettingsUrl = useKibanaUrl('/app/management/kibana/settings', { + query: { + query: 'category:(observability)', + }, + }); + + if (!uiSettings.get(enableInspectEsQueries)) { + return null; + } + + return ( + + + + + {i18n.translate( + 'xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description.advancedSettings', + { defaultMessage: 'Advanced Setting' } + )} + + ), + }} + /> + + + + ); +} + export function SearchBar({ prepend, showTimeComparison = false, @@ -38,26 +91,29 @@ export function SearchBar({ const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; return ( - - - - - - - {showTimeComparison && ( - - + <> + + + + + + + + {showTimeComparison && ( + + + + )} + + - )} - - - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 024deca558497..9a910787d5fe8 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -116,8 +116,8 @@ export function MockApmPluginContextWrapper({ children?: React.ReactNode; value?: ApmPluginContextValue; }) { - if (value.core?.http) { - createCallApmApi(value.core?.http); + if (value.core) { + createCallApmApi(value.core); } return ( { if (start && end) { return callApmApi({ @@ -51,9 +53,9 @@ export function useEnvironmentsFetcher({ ); const environmentOptions = useMemo( - () => getEnvironmentOptions(environments), - [environments] + () => getEnvironmentOptions(data.environments), + [data?.environments] ); - return { environments, status, environmentOptions }; + return { environments: data.environments, status, environmentOptions }; } diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 4ddd10ecc1476..382053f133950 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -9,7 +9,7 @@ import { ConfigSchema } from '.'; import { FetchDataParams, HasDataParams, - ObservabilityPluginSetup, + ObservabilityPublicSetup, } from '../../observability/public'; import { AppMountParameters, @@ -52,7 +52,7 @@ export interface ApmPluginSetupDeps { home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; - observability?: ObservabilityPluginSetup; + observability?: ObservabilityPublicSetup; } export interface ApmPluginStartDeps { @@ -85,19 +85,19 @@ export class ApmPlugin implements Plugin { const getApmDataHelper = async () => { const { fetchObservabilityOverviewPageData, - hasData, + getHasData, createCallApmApi, } = await import('./services/rest/apm_observability_overview_fetchers'); // have to do this here as well in case app isn't mounted yet - createCallApmApi(core.http); + createCallApmApi(core); - return { fetchObservabilityOverviewPageData, hasData }; + return { fetchObservabilityOverviewPageData, getHasData }; }; plugins.observability.dashboard.register({ appName: 'apm', hasData: async () => { const dataHelper = await getApmDataHelper(); - return await dataHelper.hasData(); + return await dataHelper.getHasData(); }, fetchData: async (params: FetchDataParams) => { const dataHelper = await getApmDataHelper(); @@ -112,7 +112,7 @@ export class ApmPlugin implements Plugin { createCallApmApi, } = await import('./components/app/RumDashboard/ux_overview_fetchers'); // have to do this here as well in case app isn't mounted yet - createCallApmApi(core.http); + createCallApmApi(core); return { fetchUxOverviewDate, hasRumData }; }; diff --git a/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts index f9e72bff231f4..f334212536778 100644 --- a/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts @@ -8,14 +8,14 @@ import { difference, zipObject } from 'lodash'; import { EuiTheme } from '../../../../../src/plugins/kibana_react/common'; import { asTransactionRate } from '../../common/utils/formatters'; -import { TimeSeries } from '../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../typings/timeseries'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; export type ThroughputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; export interface ThroughputChart { - throughputTimeseries: TimeSeries[]; + throughputTimeseries: Array>; } export function getThroughputChartSelector({ diff --git a/x-pack/plugins/apm/public/services/callApi.test.ts b/x-pack/plugins/apm/public/services/callApi.test.ts index cdd9cb5b08a32..5f0be1b6fadbb 100644 --- a/x-pack/plugins/apm/public/services/callApi.test.ts +++ b/x-pack/plugins/apm/public/services/callApi.test.ts @@ -7,49 +7,51 @@ import { mockNow } from '../utils/testHelpers'; import { clearCache, callApi } from './rest/callApi'; -import { SessionStorageMock } from './__mocks__/SessionStorageMock'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart, HttpSetup } from 'kibana/public'; -type HttpMock = HttpSetup & { - get: jest.SpyInstance; +type CoreMock = CoreStart & { + http: { + get: jest.SpyInstance; + }; }; describe('callApi', () => { - let http: HttpMock; + let core: CoreMock; beforeEach(() => { - http = ({ - get: jest.fn().mockReturnValue({ - my_key: 'hello_world', - }), - } as unknown) as HttpMock; - - // @ts-expect-error - global.sessionStorage = new SessionStorageMock(); + core = ({ + http: { + get: jest.fn().mockReturnValue({ + my_key: 'hello_world', + }), + }, + uiSettings: { get: () => false }, // disable `observability:enableInspectEsQueries` setting + } as unknown) as CoreMock; }); afterEach(() => { - http.get.mockClear(); + core.http.get.mockClear(); clearCache(); }); - describe('apm_debug', () => { + describe('_inspect', () => { beforeEach(() => { - sessionStorage.setItem('apm_debug', 'true'); + // @ts-expect-error + core.uiSettings.get = () => true; // enable `observability:enableInspectEsQueries` setting }); it('should add debug param for APM endpoints', async () => { - await callApi(http, { pathname: `/api/apm/status/server` }); + await callApi(core, { pathname: `/api/apm/status/server` }); - expect(http.get).toHaveBeenCalledWith('/api/apm/status/server', { - query: { _debug: true }, + expect(core.http.get).toHaveBeenCalledWith('/api/apm/status/server', { + query: { _inspect: true }, }); }); it('should not add debug param for non-APM endpoints', async () => { - await callApi(http, { pathname: `/api/kibana` }); + await callApi(core, { pathname: `/api/kibana` }); - expect(http.get).toHaveBeenCalledWith('/api/kibana', { query: {} }); + expect(core.http.get).toHaveBeenCalledWith('/api/kibana', { query: {} }); }); }); @@ -65,138 +67,138 @@ describe('callApi', () => { describe('when the call does not contain start/end params', () => { it('should not return cached response for identical calls', async () => { - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); }); describe('when the call contains start/end params', () => { it('should return cached response for identical calls', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should not return cached response for subsequent calls if arguments change', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar1' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar2' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar3' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should not return cached response if `end` is a future timestamp', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should return cached response if calls contain `end` param in the past', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should return cached response even if order of properties change', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2010', start: '2009' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { query: { start: '2009', end: '2010' }, pathname: `/api/kibana`, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should not return cached response with `isCachable: false` option', async () => { - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should return cached response with `isCachable: true` option', async () => { - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/apm/public/services/callApmApi.test.ts b/x-pack/plugins/apm/public/services/callApmApi.test.ts index 25d34b5d102f5..56146c49fc57d 100644 --- a/x-pack/plugins/apm/public/services/callApmApi.test.ts +++ b/x-pack/plugins/apm/public/services/callApmApi.test.ts @@ -7,7 +7,7 @@ import * as callApiExports from './rest/callApi'; import { createCallApmApi, callApmApi } from './rest/createCallApmApi'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; const callApi = jest .spyOn(callApiExports, 'callApi') @@ -15,7 +15,7 @@ const callApi = jest describe('callApmApi', () => { beforeEach(() => { - createCallApmApi({} as HttpSetup); + createCallApmApi({} as CoreStart); }); afterEach(() => { @@ -79,7 +79,7 @@ describe('callApmApi', () => { {}, expect.objectContaining({ pathname: '/api/apm', - method: 'POST', + method: 'post', body: { foo: 'bar', bar: 'foo', diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts index b0bae6aa91a3d..1821e92ee5a78 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import { fetchObservabilityOverviewPageData, - hasData, + getHasData, } from './apm_observability_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; @@ -31,12 +31,12 @@ describe('Observability dashboard data', () => { describe('hasData', () => { it('returns false when no data is available', async () => { callApmApiMock.mockImplementation(() => Promise.resolve(false)); - const response = await hasData(); + const response = await getHasData(); expect(response).toBeFalsy(); }); it('returns true when data is available', async () => { - callApmApiMock.mockImplementation(() => Promise.resolve(true)); - const response = await hasData(); + callApmApiMock.mockResolvedValue({ hasData: true }); + const response = await getHasData(); expect(response).toBeTruthy(); }); }); diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts index 6d630ede1cb11..55ead8d942aca 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts @@ -58,9 +58,11 @@ export const fetchObservabilityOverviewPageData = async ({ }; }; -export async function hasData() { - return await callApmApi({ +export async function getHasData() { + const res = await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_data', signal: null, }); + + return res.hasData; } diff --git a/x-pack/plugins/apm/public/services/rest/callApi.ts b/x-pack/plugins/apm/public/services/rest/callApi.ts index f5106fce78cc7..f623872303c5b 100644 --- a/x-pack/plugins/apm/public/services/rest/callApi.ts +++ b/x-pack/plugins/apm/public/services/rest/callApi.ts @@ -5,15 +5,19 @@ * 2.0. */ -import { HttpSetup } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; import { isString, startsWith } from 'lodash'; import LRU from 'lru-cache'; import hash from 'object-hash'; +import { enableInspectEsQueries } from '../../../../observability/public'; import { FetchOptions } from '../../../common/fetch_options'; -function fetchOptionsWithDebug(fetchOptions: FetchOptions) { +function fetchOptionsWithDebug( + fetchOptions: FetchOptions, + inspectableEsQueriesEnabled: boolean +) { const debugEnabled = - sessionStorage.getItem('apm_debug') === 'true' && + inspectableEsQueriesEnabled && startsWith(fetchOptions.pathname, '/api/apm'); const { body, ...rest } = fetchOptions; @@ -23,7 +27,7 @@ function fetchOptionsWithDebug(fetchOptions: FetchOptions) { ...(body !== undefined ? { body: JSON.stringify(body) } : {}), query: { ...fetchOptions.query, - ...(debugEnabled ? { _debug: true } : {}), + ...(debugEnabled ? { _inspect: true } : {}), }, }; } @@ -37,9 +41,12 @@ export function clearCache() { export type CallApi = typeof callApi; export async function callApi( - http: HttpSetup, + { http, uiSettings }: CoreStart | CoreSetup, fetchOptions: FetchOptions ): Promise { + const inspectableEsQueriesEnabled: boolean = uiSettings.get( + enableInspectEsQueries + ); const cacheKey = getCacheKey(fetchOptions); const cacheResponse = cache.get(cacheKey); if (cacheResponse) { @@ -47,7 +54,8 @@ export async function callApi( } const { pathname, method = 'get', ...options } = fetchOptionsWithDebug( - fetchOptions + fetchOptions, + inspectableEsQueriesEnabled ); const lowercaseMethod = method.toLowerCase() as diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index c6d55a85dd70e..b0cce3296fe21 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { HttpSetup } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { parseEndpoint } from '../../../common/apm_api/parse_endpoint'; import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { APMAPI } from '../../../server/routes/create_apm_api'; +import type { APMAPI } from '../../../server/routes/create_apm_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Client } from '../../../server/routes/typings'; +import type { Client } from '../../../server/routes/typings'; export type APMClient = Client; export type AutoAbortedAPMClient = Client; @@ -24,8 +25,8 @@ export type APMClientOptions = Omit< signal: AbortSignal | null; params?: { body?: any; - query?: any; - path?: any; + query?: Record; + path?: Record; }; }; @@ -35,23 +36,17 @@ export let callApmApi: APMClient = () => { ); }; -export function createCallApmApi(http: HttpSetup) { +export function createCallApmApi(core: CoreStart | CoreSetup) { callApmApi = ((options: APMClientOptions) => { - const { endpoint, params = {}, ...opts } = options; + const { endpoint, params, ...opts } = options; + const { method, pathname } = parseEndpoint(endpoint, params?.path); - const path = (params.path || {}) as Record; - const [method, pathname] = endpoint.split(' '); - - const formattedPathname = Object.keys(path).reduce((acc, paramName) => { - return acc.replace(`{${paramName}}`, path[paramName]); - }, pathname); - - return callApi(http, { + return callApi(core, { ...opts, method, - pathname: formattedPathname, - body: params.body, - query: params.query, + pathname, + body: params?.body, + query: params?.query, }); }) as APMClient; } diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index b35024844a892..ef2675f4f6c65 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -31,19 +31,19 @@ _Docker Compose is required_ ## Testing -### E2E (Cypress) tests +### Cypress tests ```sh -x-pack/plugins/apm/e2e/run-e2e.sh +node x-pack/plugins/apm/scripts/ftr_e2e/cypress_run.js ``` _Starts Kibana (:5701), APM Server (:8201) and Elasticsearch (:9201). Ingests sample data into Elasticsearch via APM Server and runs the Cypress tests_ -### Unit testing +### Jest tests Note: Run the following commands from `kibana/x-pack/plugins/apm`. -#### Run unit tests +#### Run ``` npx jest --watch @@ -82,8 +82,11 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### API integration tests -Our tests are separated in two suites: one suite runs with a basic license, and the other -with a trial license (the equivalent of gold+). This requires separate test servers and test runners. +API tests are separated in two suites: + - a basic license test suite + - a trial license test suite (the equivalent of gold+) + +This requires separate test servers and test runners. **Basic** @@ -109,7 +112,10 @@ node scripts/functional_test_runner --config x-pack/test/apm_api_integration/tri The API tests for "trial" are located in `x-pack/test/apm_api_integration/trial/tests`. -For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) + +**API Test tips** + - For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) + - To update snapshots append `--updateSnapshots` to the functional_test_runner command ## Linting @@ -154,10 +160,10 @@ The users will be created with the password specified in kibana.dev.yml for `ela ## Debugging Elasticsearch queries -All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. +All APM api endpoints accept `_inspect=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. Example: -`/api/apm/services/my_service?_debug=true` +`/api/apm/services/my_service?_inspect=true` ## Storybook diff --git a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts index 7eb6aba0d005c..7f62fd3998060 100644 --- a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts +++ b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts @@ -10,6 +10,7 @@ import { execSync } from 'child_process'; import moment from 'moment'; import path from 'path'; import fs from 'fs'; +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { getEsClient } from '../shared/get_es_client'; import { parseIndexUrl } from '../shared/parse_index_url'; @@ -116,7 +117,7 @@ async function run() { const query = { bool: { - should: should.map(({ bool }) => ({ bool })), + should: should.map(({ bool }) => ({ bool })) as QueryContainer[], minimum_should_match: 1, }, }; diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index e4aedf452002d..25554eeeaf81d 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -13,7 +13,6 @@ // - Validate whether we can run the queries we want to on the telemetry data import { merge, chunk, flatten, omit } from 'lodash'; -import { Client } from '@elastic/elasticsearch'; import { argv } from 'yargs'; import { Logger } from 'kibana/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -26,6 +25,7 @@ import { generateSampleDocuments } from './generate-sample-documents'; import { readKibanaConfig } from '../shared/read-kibana-config'; import { getHttpAuth } from '../shared/get-http-auth'; import { createOrUpdateIndex } from '../shared/create-or-update-index'; +import { getEsClient } from '../shared/get_es_client'; async function uploadData() { const githubToken = process.env.GITHUB_TOKEN; @@ -43,8 +43,8 @@ async function uploadData() { const httpAuth = getHttpAuth(config); - const client = new Client({ - nodes: [config['elasticsearch.hosts']], + const client = getEsClient({ + node: config['elasticsearch.hosts'], ...(httpAuth ? { auth: { ...httpAuth, username: 'elastic' }, @@ -83,10 +83,10 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return unwrapEsResponse(client.search(body as any)); + return unwrapEsResponse(client.search(body)) as Promise; }, indicesStats: (body) => { - return unwrapEsResponse(client.indices.stats(body)); + return unwrapEsResponse(client.indices.stats(body)); }, transportRequest: ((params) => { return unwrapEsResponse( diff --git a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts index c4fef64f515d1..9a0ba514bb479 100644 --- a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts +++ b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts @@ -25,8 +25,8 @@ export function alertingEsClient( >, params: TParams ): Promise>> { - return services.scopedClusterClient.asCurrentUser.search({ + return (services.scopedClusterClient.asCurrentUser.search({ ...params, ignore_unavailable: true, - }); + }) as unknown) as Promise>>; } diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts index bea90109725d0..508b9419344cd 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { MetricsAggregationResponsePart } from '../../../../../../../typings/elasticsearch/aggregations'; +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { PROCESSOR_EVENT, SERVICE_NAME, @@ -45,7 +45,7 @@ export function getTransactionDurationChartPreview({ : []), ...rangeQuery(start, end), ...environmentQuery(environment), - ], + ] as QueryContainer[], }, }; @@ -85,7 +85,7 @@ export function getTransactionDurationChartPreview({ const x = bucket.key; const y = aggregationType === 'avg' - ? (bucket.agg as MetricsAggregationResponsePart).value + ? (bucket.agg as { value: number | null }).value : (bucket.agg as { values: Record }).values[ percentilesKey ]; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts index 167cb133102f2..d7dd7aee3ca25 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts @@ -54,10 +54,20 @@ describe('Error count alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 0, }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); @@ -89,7 +99,9 @@ describe('Error count alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 2, }, }, @@ -111,6 +123,14 @@ describe('Error count alert', () => { ], }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); @@ -177,7 +197,9 @@ describe('Error count alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 2, }, }, @@ -193,6 +215,14 @@ describe('Error count alert', () => { ], }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts index c18f29b6267e0..148cd813a8a22 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts @@ -52,10 +52,20 @@ describe('Transaction error rate alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 0, }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); @@ -87,7 +97,9 @@ describe('Transaction error rate alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 4, }, }, @@ -126,6 +138,14 @@ describe('Transaction error rate alert', () => { ], }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); @@ -196,7 +216,9 @@ describe('Transaction error rate alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { + relation: 'eq', value: 4, }, }, @@ -221,6 +243,14 @@ describe('Transaction error rate alert', () => { ], }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); @@ -274,8 +304,10 @@ describe('Transaction error rate alert', () => { services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { + hits: [], total: { value: 4, + relation: 'eq', }, }, aggregations: { @@ -286,6 +318,14 @@ describe('Transaction error rate alert', () => { buckets: [{ key: 'foo' }, { key: 'bar' }], }, }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, }) ); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts index 98063e3e1e3fd..87686d2c30cae 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts @@ -7,7 +7,7 @@ import { merge } from 'lodash'; import { Logger } from 'kibana/server'; -import { RequestParams } from '@elastic/elasticsearch'; +import { IndicesStats } from '@elastic/elasticsearch/api/requestParams'; import { ESSearchRequest, ESSearchResponse, @@ -22,7 +22,7 @@ type TelemetryTaskExecutor = (params: { params: TSearchRequest ): Promise>; indicesStats( - params: RequestParams.IndicesStats + params: IndicesStats // promise returned by client has an abort property // so we cannot use its ReturnType ): Promise<{ diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index e9744c6614641..e2a39b521466a 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { ValuesType } from 'utility-types'; import { flatten, merge, sortBy, sum, pickBy } from 'lodash'; -import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch/aggregations'; +import { CompositeAggregationSource } from '@elastic/elasticsearch/api/types'; +import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { ProcessorEvent } from '../../../../common/processor_event'; import { TelemetryTask } from '.'; import { AGENT_NAMES, RUM_AGENT_NAMES } from '../../../../common/agent_name'; @@ -60,9 +59,7 @@ export const tasks: TelemetryTask[] = [ // the transaction count for that time range. executor: async ({ indices, search }) => { async function getBucketCountFromPaginatedQuery( - sources: Array< - ValuesType[string] - >, + sources: CompositeAggregationSource[], prevResult?: { transaction_count: number; expected_metric_document_count: number; @@ -151,7 +148,7 @@ export const tasks: TelemetryTask[] = [ }, size: 1, sort: { - '@timestamp': 'desc', + '@timestamp': 'desc' as const, }, }, }) @@ -317,7 +314,7 @@ export const tasks: TelemetryTask[] = [ service_environments: { composite: { size: 1000, - sources: [ + sources: asMutableArray([ { [SERVICE_ENVIRONMENT]: { terms: { @@ -333,7 +330,7 @@ export const tasks: TelemetryTask[] = [ }, }, }, - ], + ] as const), }, }, }, diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index 848da41bc8372..88ef1203bae9f 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -76,7 +76,7 @@ export async function createApmTelemetry({ }); const search: CollectTelemetryParams['search'] = (params) => - unwrapEsResponse(esClient.asInternalUser.search(params)); + unwrapEsResponse(esClient.asInternalUser.search(params)) as any; const indicesStats: CollectTelemetryParams['indicesStats'] = (params) => unwrapEsResponse(esClient.asInternalUser.indices.stats(params)); diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index f613a0dbca402..c668f3bb28713 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -11,7 +11,7 @@ import { processSignificantTermAggs, TopSigTerm, } from '../process_significant_term_aggs'; -import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch/aggregations'; +import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch'; import { ESFilter } from '../../../../../../../typings/elasticsearch'; import { environmentQuery, diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts index b800a21ffc341..88b1cf3a344ed 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts @@ -6,7 +6,7 @@ */ import { isEmpty, dropRightWhile } from 'lodash'; -import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch/aggregations'; +import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch'; import { ESFilter } from '../../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; @@ -106,7 +106,7 @@ export async function getLatencyDistribution({ type Agg = NonNullable; if (!response.aggregations) { - return; + return {}; } function formatDistribution(distribution: Agg['distribution']) { diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts index 6afca46ec7391..9472d385a26c6 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch/aggregations'; +import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch'; import { ESFilter } from '../../../../../../../typings/elasticsearch'; import { environmentQuery, @@ -73,6 +73,10 @@ export async function getCorrelationsForSlowTransactions({ setup, }); + if (!durationForPercentile) { + return {}; + } + const response = await withApmSpan('get_significant_terms', () => { const params = { apm: { events: [ProcessorEvent.transaction] }, diff --git a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts index 2732cd45c342e..cc1e32e47973d 100644 --- a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts +++ b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts @@ -9,7 +9,7 @@ import { orderBy } from 'lodash'; import { AggregationOptionsByType, AggregationResultOf, -} from '../../../../../../typings/elasticsearch/aggregations'; +} from '../../../../../../typings/elasticsearch'; export interface TopSigTerm { fieldName: string; @@ -47,10 +47,15 @@ function getMaxImpactScore(scores: number[]) { export function processSignificantTermAggs({ sigTermAggs, }: { - sigTermAggs: Record; + sigTermAggs: Record; }) { - const significantTerms = Object.entries(sigTermAggs).flatMap( - ([fieldName, agg]) => { + const significantTerms = Object.entries(sigTermAggs) + // filter entries with buckets, i.e. Significant terms aggs + .filter((entry): entry is [string, SigTermAgg] => { + const [, agg] = entry; + return 'buckets' in agg; + }) + .flatMap(([fieldName, agg]) => { return agg.buckets.map((bucket) => ({ fieldName, fieldValue: bucket.key, @@ -58,8 +63,7 @@ export function processSignificantTermAggs({ valueCount: bucket.doc_count, score: bucket.score, })); - } - ); + }); const maxImpactScore = getMaxImpactScore( significantTerms.map(({ score }) => score) diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts index d4ad2c8a9b2cb..57fb486180993 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { ERROR_GROUP_ID, SERVICE_NAME, @@ -54,10 +55,10 @@ export function getErrorGroupSample({ should: [{ term: { [TRANSACTION_SAMPLED]: true } }], }, }, - sort: [ + sort: asMutableArray([ { _score: 'desc' }, // sort by _score first to ensure that errors with transaction.sampled:true ends up on top { '@timestamp': { order: 'desc' } }, // sort by timestamp to get the most recent error - ], + ] as const), }, }; diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts index 1c262ebf882b2..f5b22e5349756 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { SortOptions } from '../../../../../../typings/elasticsearch/aggregations'; import { ERROR_CULPRIT, ERROR_EXC_HANDLED, @@ -48,7 +47,7 @@ export function getErrorGroups({ serviceName, }); - const order: SortOptions = sortByLatestOccurrence + const order = sortByLatestOccurrence ? { max_timestamp: sortDirection, } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index aa41880fba444..1f0aa401bcab0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -7,8 +7,10 @@ /* eslint-disable no-console */ +import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; +import { inspectableEsQueriesMap } from '../../../routes/create_api'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); @@ -18,10 +20,18 @@ export async function callAsyncWithDebug({ cb, getDebugMessage, debug, + request, + requestType, + requestParams, + isCalledWithInternalUser, }: { cb: () => Promise; getDebugMessage: () => { body: string; title: string }; debug: boolean; + request: KibanaRequest; + requestType: string; + requestParams: Record; + isCalledWithInternalUser: boolean; // only allow inspection of queries that were retrieved with credentials of the end user }) { if (!debug) { return cb(); @@ -41,16 +51,27 @@ export async function callAsyncWithDebug({ if (debug) { const highlightColor = esError ? 'bgRed' : 'inverse'; const diff = process.hrtime(startTime); - const duration = `${Math.round(diff[0] * 1000 + diff[1] / 1e6)}ms`; + const duration = Math.round(diff[0] * 1000 + diff[1] / 1e6); // duration in ms const { title, body } = getDebugMessage(); console.log( - chalk.bold[highlightColor](`=== Debug: ${title} (${duration}) ===`) + chalk.bold[highlightColor](`=== Debug: ${title} (${duration}ms) ===`) ); console.log(body); console.log(`\n`); + + const inspectableEsQueries = inspectableEsQueriesMap.get(request); + if (!isCalledWithInternalUser && inspectableEsQueries) { + inspectableEsQueries.push({ + response: res, + duration, + requestType, + requestParams: omit(requestParams, 'headers'), + esError: esError?.response ?? esError?.message, + }); + } } if (esError) { @@ -62,13 +83,13 @@ export async function callAsyncWithDebug({ export const getDebugBody = ( params: Record, - operationName: string + requestType: string ) => { - if (operationName === 'search') { + if (requestType === 'search') { return `GET ${params.index}/_search\n${formatObj(params.body)}`; } - return `${chalk.bold('ES operation:')} ${operationName}\n${chalk.bold( + return `${chalk.bold('ES operation:')} ${requestType}\n${chalk.bold( 'ES query:' )}\n${formatObj(params)}`; }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index e04b3a70a7593..b8a14253a229a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -13,7 +13,7 @@ import { } from '../../../../../../../../src/core/server'; import { ESSearchRequest, - ESSearchResponse, + InferSearchResponseOf, } from '../../../../../../../../typings/elasticsearch'; import { unwrapEsResponse } from '../../../../../../observability/server'; import { ProcessorEvent } from '../../../../../common/processor_event'; @@ -54,7 +54,7 @@ type ESSearchRequestOf = Omit< type TypedSearchResponse< TParams extends APMEventESSearchRequest -> = ESSearchResponse< +> = InferSearchResponseOf< TypeOfProcessorEvent>, ESSearchRequestOf >; @@ -93,6 +93,9 @@ export function createApmEventClient({ ignore_unavailable: true, }; + // only "search" operation is currently supported + const requestType = 'search'; + return callAsyncWithDebug({ cb: () => { const searchPromise = cancelEsRequestOnAbort( @@ -103,10 +106,14 @@ export function createApmEventClient({ return unwrapEsResponse(searchPromise); }, getDebugMessage: () => ({ - body: getDebugBody(searchParams, 'search'), + body: getDebugBody(searchParams, requestType), title: getDebugTitle(request), }), + isCalledWithInternalUser: false, debug, + request, + requestType, + requestParams: searchParams, }); }, }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts index 4faf80d7ca8db..45e17c1678518 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts @@ -6,8 +6,12 @@ */ import { KibanaRequest } from 'src/core/server'; -import { RequestParams } from '@elastic/elasticsearch'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; +import { + CreateIndexRequest, + DeleteRequest, + IndexRequest, +} from '@elastic/elasticsearch/api/types'; import { unwrapEsResponse } from '../../../../../../observability/server'; import { APMRequestHandlerContext } from '../../../../routes/typings'; import { @@ -21,7 +25,7 @@ import { } from '../call_async_with_debug'; import { cancelEsRequestOnAbort } from '../cancel_es_request_on_abort'; -export type APMIndexDocumentParams = RequestParams.Index; +export type APMIndexDocumentParams = IndexRequest; export type APMInternalClient = ReturnType; @@ -36,10 +40,10 @@ export function createInternalESClient({ function callEs({ cb, - operationName, + requestType, params, }: { - operationName: string; + requestType: string; cb: () => TransportRequestPromise; params: Record; }) { @@ -47,9 +51,13 @@ export function createInternalESClient({ cb: () => unwrapEsResponse(cancelEsRequestOnAbort(cb(), request)), getDebugMessage: () => ({ title: getDebugTitle(request), - body: getDebugBody(params, operationName), + body: getDebugBody(params, requestType), }), - debug: context.params.query._debug, + debug: context.params.query._inspect, + isCalledWithInternalUser: true, + request, + requestType, + requestParams: params, }); } @@ -61,28 +69,28 @@ export function createInternalESClient({ params: TSearchRequest ): Promise> => { return callEs({ - operationName: 'search', + requestType: 'search', cb: () => asInternalUser.search(params), params, }); }, index: (params: APMIndexDocumentParams) => { return callEs({ - operationName: 'index', + requestType: 'index', cb: () => asInternalUser.index(params), params, }); }, - delete: (params: RequestParams.Delete): Promise<{ result: string }> => { + delete: (params: DeleteRequest): Promise<{ result: string }> => { return callEs({ - operationName: 'delete', + requestType: 'delete', cb: () => asInternalUser.delete(params), params, }); }, - indicesCreate: (params: RequestParams.IndicesCreate) => { + indicesCreate: (params: CreateIndexRequest) => { return callEs({ - operationName: 'indices.create', + requestType: 'indices.create', cb: () => asInternalUser.indices.create(params), params, }); diff --git a/x-pack/plugins/apm/server/lib/helpers/input_validation.ts b/x-pack/plugins/apm/server/lib/helpers/input_validation.ts index 5c188ff0d093e..0a34711b9b40d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/input_validation.ts +++ b/x-pack/plugins/apm/server/lib/helpers/input_validation.ts @@ -14,7 +14,7 @@ export const withDefaultValidators = ( validators: { [key: string]: Schema } = {} ) => { return Joi.object().keys({ - _debug: Joi.bool(), + _inspect: Joi.bool(), start: dateValidation, end: dateValidation, uiFilters: Joi.string(), diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 30b81a15f5efa..c0707d0286180 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -51,7 +51,7 @@ function getMockRequest() { ) as APMConfig, params: { query: { - _debug: false, + _inspect: false, }, }, core: { @@ -157,7 +157,9 @@ describe('setupRequest', () => { apm: { events: [ProcessorEvent.transaction], }, - body: { query: { bool: { filter: [{ term: 'someTerm' }] } } }, + body: { + query: { bool: { filter: [{ term: { field: 'someTerm' } }] } }, + }, }); const params = mockContext.core.elasticsearch.client.asCurrentUser.search.mock @@ -166,7 +168,7 @@ describe('setupRequest', () => { query: { bool: { filter: [ - { term: 'someTerm' }, + { term: { field: 'someTerm' } }, { terms: { [PROCESSOR_EVENT]: ['transaction'] } }, { range: { 'observer.version_major': { gte: 7 } } }, ], @@ -183,7 +185,9 @@ describe('setupRequest', () => { apm: { events: [ProcessorEvent.error], }, - body: { query: { bool: { filter: [{ term: 'someTerm' }] } } }, + body: { + query: { bool: { filter: [{ term: { field: 'someTerm' } }] } }, + }, }, { includeLegacyData: true, @@ -196,7 +200,7 @@ describe('setupRequest', () => { query: { bool: { filter: [ - { term: 'someTerm' }, + { term: { field: 'someTerm' } }, { terms: { [PROCESSOR_EVENT]: ['error'], diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 60fb9a8bfa85a..fff661250c6df 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -45,7 +45,7 @@ export interface SetupTimeRange { interface SetupRequestParams { query?: { - _debug?: boolean; + _inspect?: boolean; /** * Timestamp in ms since epoch @@ -88,7 +88,7 @@ export async function setupRequest( indices, apmEventClient: createApmEventClient({ esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._debug, + debug: context.params.query._inspect, request, indices, options: { includeFrozen }, diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts index 9bec5eb4a247c..11d65b7697e9a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts @@ -10,7 +10,7 @@ import { EventOutcome } from '../../../common/event_outcome'; import { AggregationOptionsByType, AggregationResultOf, -} from '../../../../../../typings/elasticsearch/aggregations'; +} from '../../../../../../typings/elasticsearch'; export const getOutcomeAggregation = () => ({ terms: { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index 8d0acb7f85f5d..0b7f82c0b8388 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -21,20 +21,20 @@ export async function createStaticIndexPattern( setup: Setup, context: APMRequestHandlerContext, savedObjectsClient: InternalSavedObjectsClient -): Promise { +): Promise { return withApmSpan('create_static_index_pattern', async () => { const { config } = context; // don't autocreate APM index pattern if it's been disabled via the config if (!config['xpack.apm.autocreateApmIndexPattern']) { - return; + return false; } // Discover and other apps will throw errors if an index pattern exists without having matching indices. // The following ensures the index pattern is only created if APM data is found const hasData = await hasHistoricalAgentData(setup); if (!hasData) { - return; + return false; } try { @@ -49,12 +49,12 @@ export async function createStaticIndexPattern( { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } ) ); - return; + return true; } catch (e) { // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown // that error should be silenced if (SavedObjectsErrorHelpers.isConflictError(e)) { - return; + return false; } throw e; } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index 9f83af989fc57..3b3ef8b9c4bcf 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -7,6 +7,7 @@ import { sum, round } from 'lodash'; import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { isFiniteNumber } from '../../../../../../common/utils/is_finite_number'; import { Setup, SetupTimeRange } from '../../../../helpers/setup_request'; import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; import { ChartBase } from '../../../types'; @@ -126,10 +127,9 @@ export async function fetchAndTransformGcMetrics({ const data = timeseriesData.buckets.map((bucket) => { // derivative/value will be undefined for the first hit and if the `max` value is null const bucketValue = bucket.value?.value; - const y = - bucketValue !== null && bucketValue !== undefined && bucket.value - ? round(bucketValue * (60 / bucketSize), 1) - : null; + const y = isFiniteNumber(bucketValue) + ? round(bucketValue * (60 / bucketSize), 1) + : null; return { y, diff --git a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts index abdc8da78502c..bbe13874d7d3b 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts @@ -9,7 +9,7 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; -export function hasData({ setup }: { setup: Setup }) { +export function getHasData({ setup }: { setup: Setup }) { return withApmSpan('observability_overview_has_apm_data', async () => { const { apmEventClient } = setup; try { diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts index df51bf30c1a53..0f1d7146f8459 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts @@ -144,12 +144,16 @@ export async function getPageLoadDistribution({ } // calculate the diff to get actual page load on specific duration value - let pageDist = pageDistVals.map(({ key, value }, index: number, arr) => { - return { - x: microToSec(key), - y: index === 0 ? value : value - arr[index - 1].value, - }; - }); + let pageDist = pageDistVals.map( + ({ key, value: maybeNullValue }, index: number, arr) => { + // FIXME: values from percentile* aggs can be null + const value = maybeNullValue!; + return { + x: microToSec(key), + y: index === 0 ? value : value - arr[index - 1].value!, + }; + } + ); pageDist = removeZeroesFromTail(pageDist); diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts index 12b7961d51610..6a6caab953733 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts @@ -98,10 +98,12 @@ export const getPageLoadDistBreakdown = async ({ return pageDistBreakdowns?.map(({ key, page_dist: pageDist }) => { let seriesData = pageDist.values?.map( - ({ key: pKey, value }, index: number, arr) => { + ({ key: pKey, value: maybeNullValue }, index: number, arr) => { + // FIXME: values from percentile* aggs can be null + const value = maybeNullValue!; return { x: microToSec(pKey), - y: index === 0 ? value : value - arr[index - 1].value, + y: index === 0 ? value : value - arr[index - 1].value!, }; } ); diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts index 88228f33dd3af..9bde701df5672 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts @@ -117,7 +117,7 @@ export async function getWebCoreVitals({ } = response.aggregations ?? {}; const getRanksPercentages = ( - ranks?: Array<{ key: number; value: number }> + ranks?: Array<{ key: number; value: number | null }> ) => { const ranksVal = ranks?.map(({ value }) => value?.toFixed(0) ?? 0) ?? []; return [ diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index 8c97a3993e8c0..bcddbff34a8f6 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { sortBy, uniqBy } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { MlPluginSetup } from '../../../../ml/server'; import { PromiseReturnType } from '../../../../observability/typings/common'; @@ -63,7 +64,7 @@ export async function getServiceAnomalies({ by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], }, }, - ], + ] as estypes.QueryContainer[], }, }, aggs: { @@ -73,7 +74,7 @@ export async function getServiceAnomalies({ sources: [ { serviceName: { terms: { field: 'partition_field_value' } } }, { jobId: { terms: { field: 'job_id' } } }, - ], + ] as Array>, }, aggs: { metrics: { @@ -83,7 +84,7 @@ export async function getServiceAnomalies({ { field: 'by_field_value' }, { field: 'result_type' }, { field: 'record_score' }, - ] as const, + ], sort: { record_score: 'desc' as const, }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index 8bc1b1f0562f5..fa04b963388b2 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { sortBy, take, uniq } from 'lodash'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { ESFilter } from '../../../../../../typings/elasticsearch'; import { SERVICE_ENVIRONMENT, @@ -73,7 +74,7 @@ export function getTraceSampleIds({ aggs: { connections: { composite: { - sources: [ + sources: asMutableArray([ { [SPAN_DESTINATION_SERVICE_RESOURCE]: { terms: { @@ -96,7 +97,7 @@ export function getTraceSampleIds({ }, }, }, - ], + ] as const), size: fingerprintBucketSize, }, aggs: { diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 7e7e073c0d2f6..9d05369aca840 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -131,9 +131,11 @@ Array [ }, "sample": Object { "top_metrics": Object { - "metrics": Object { - "field": "agent.name", - }, + "metrics": Array [ + Object { + "field": "agent.name", + }, + ], "sort": Object { "@timestamp": "desc", }, @@ -213,9 +215,11 @@ Array [ }, "latest": Object { "top_metrics": Object { - "metrics": Object { - "field": "agent.name", - }, + "metrics": Array [ + Object { + "field": "agent.name", + }, + ], "sort": Object { "@timestamp": "desc", }, diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index e0329e5f60e19..3e1a8f26de6b4 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -57,17 +57,17 @@ export function getStoredAnnotations({ const response: ESSearchResponse< ESAnnotation, { body: typeof body } - > = await unwrapEsResponse( - client.search({ + > = await (unwrapEsResponse( + client.search({ index: annotationsClient.index, body, }) - ); + ) as any); return response.hits.hits.map((hit) => { return { type: AnnotationType.VERSION, - id: hit._id, + id: hit._id as string, '@timestamp': new Date(hit._source['@timestamp']).getTime(), text: hit._source.message, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts index e41a88649c5ff..db491012c986b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -6,6 +6,7 @@ */ import { isEqual, keyBy, mapValues } from 'lodash'; +import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { pickKeys } from '../../../../common/utils/pick_keys'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { @@ -58,7 +59,7 @@ export const getDestinationMap = ({ connections: { composite: { size: 1000, - sources: [ + sources: asMutableArray([ { [SPAN_DESTINATION_SERVICE_RESOURCE]: { terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, @@ -67,16 +68,18 @@ export const getDestinationMap = ({ // make sure we get samples for both successful // and failed calls { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, - ], + ] as const), }, aggs: { sample: { top_hits: { size: 1, _source: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID], - sort: { - '@timestamp': 'desc', - }, + sort: [ + { + '@timestamp': 'desc' as const, + }, + ], }, }, }, @@ -123,12 +126,12 @@ export const getDestinationMap = ({ }, }, size: outgoingConnections.length, - docvalue_fields: [ + docvalue_fields: asMutableArray([ SERVICE_NAME, SERVICE_ENVIRONMENT, AGENT_NAME, PARENT_ID, - ] as const, + ] as const), _source: false, }, }) diff --git a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts index 676ba1625cc61..7729822df30ca 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts @@ -90,11 +90,11 @@ export async function getServiceErrorGroups({ sample: { top_hits: { size: 1, - _source: [ + _source: ([ ERROR_LOG_MESSAGE, ERROR_EXC_MESSAGE, '@timestamp', - ], + ] as any) as string, sort: { '@timestamp': 'desc', }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts new file mode 100644 index 0000000000000..6fca42723b9cc --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/comparison_statistics.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { keyBy } from 'lodash'; +import { Coordinate } from '../../../../typings/timeseries'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { joinByKey } from '../../../../common/utils/join_by_key'; +import { offsetPreviousPeriodCoordinates } from '../../../utils/offset_previous_period_coordinate'; +import { withApmSpan } from '../../../utils/with_apm_span'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getServiceInstancesSystemMetricStatistics } from './get_service_instances_system_metric_statistics'; +import { getServiceInstancesTransactionStatistics } from './get_service_instances_transaction_statistics'; + +interface ServiceInstanceComparisonStatisticsParams { + environment?: string; + kuery?: string; + latencyAggregationType: LatencyAggregationType; + setup: Setup; + serviceName: string; + transactionType: string; + searchAggregatedTransactions: boolean; + numBuckets: number; + start: number; + end: number; + serviceNodeIds: string[]; +} + +async function getServiceInstancesComparisonStatistics( + params: ServiceInstanceComparisonStatisticsParams +): Promise< + Array<{ + serviceNodeName: string; + errorRate?: Coordinate[]; + latency?: Coordinate[]; + throughput?: Coordinate[]; + cpuUsage?: Coordinate[]; + memoryUsage?: Coordinate[]; + }> +> { + return withApmSpan( + 'get_service_instances_comparison_statistics', + async () => { + const [transactionStats, systemMetricStats = []] = await Promise.all([ + getServiceInstancesTransactionStatistics({ + ...params, + isComparisonSearch: true, + }), + getServiceInstancesSystemMetricStatistics({ + ...params, + isComparisonSearch: true, + }), + ]); + + const stats = joinByKey( + [...transactionStats, ...systemMetricStats], + 'serviceNodeName' + ); + + return stats; + } + ); +} + +export async function getServiceInstancesComparisonStatisticsPeriods({ + environment, + kuery, + latencyAggregationType, + setup, + serviceName, + transactionType, + searchAggregatedTransactions, + numBuckets, + serviceNodeIds, + comparisonStart, + comparisonEnd, +}: { + environment?: string; + kuery?: string; + latencyAggregationType: LatencyAggregationType; + setup: Setup & SetupTimeRange; + serviceName: string; + transactionType: string; + searchAggregatedTransactions: boolean; + numBuckets: number; + serviceNodeIds: string[]; + comparisonStart?: number; + comparisonEnd?: number; +}) { + return withApmSpan( + 'get_service_instances_comparison_statistics_periods', + async () => { + const { start, end } = setup; + + const commonParams = { + environment, + kuery, + latencyAggregationType, + setup, + serviceName, + transactionType, + searchAggregatedTransactions, + numBuckets, + serviceNodeIds, + }; + + const currentPeriodPromise = getServiceInstancesComparisonStatistics({ + ...commonParams, + start, + end, + }); + + const previousPeriodPromise = + comparisonStart && comparisonEnd + ? getServiceInstancesComparisonStatistics({ + ...commonParams, + start: comparisonStart, + end: comparisonEnd, + }) + : []; + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firtCurrentPeriod = currentPeriod.length + ? currentPeriod[0] + : undefined; + + return { + currentPeriod: keyBy(currentPeriod, 'serviceNodeName'), + previousPeriod: keyBy( + previousPeriod.map((data) => { + return { + ...data, + cpuUsage: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firtCurrentPeriod?.cpuUsage, + previousPeriodTimeseries: data.cpuUsage, + }), + errorRate: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firtCurrentPeriod?.errorRate, + previousPeriodTimeseries: data.errorRate, + }), + latency: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firtCurrentPeriod?.latency, + previousPeriodTimeseries: data.latency, + }), + memoryUsage: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firtCurrentPeriod?.memoryUsage, + previousPeriodTimeseries: data.memoryUsage, + }), + throughput: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firtCurrentPeriod?.throughput, + previousPeriodTimeseries: data.throughput, + }), + }; + }), + 'serviceNodeName' + ), + }; + } + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts deleted file mode 100644 index 6a72f817b3f69..0000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AggregationOptionsByType } from '../../../../../../../typings/elasticsearch'; -import { - environmentQuery, - rangeQuery, - kqlQuery, -} from '../../../../server/utils/queries'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; -import { - METRIC_CGROUP_MEMORY_USAGE_BYTES, - METRIC_PROCESS_CPU_PERCENT, - METRIC_SYSTEM_FREE_MEMORY, - METRIC_SYSTEM_TOTAL_MEMORY, - SERVICE_NAME, - SERVICE_NODE_NAME, -} from '../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../common/processor_event'; -import { ServiceInstanceParams } from '.'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { - percentCgroupMemoryUsedScript, - percentSystemMemoryUsedScript, -} from '../../metrics/by_agent/shared/memory'; -import { withApmSpan } from '../../../utils/with_apm_span'; - -export async function getServiceInstanceSystemMetricStats({ - environment, - kuery, - setup, - serviceName, - size, - numBuckets, -}: ServiceInstanceParams) { - return withApmSpan('get_service_instance_system_metric_stats', async () => { - const { apmEventClient, start, end } = setup; - - const { intervalString } = getBucketSize({ start, end, numBuckets }); - - const systemMemoryFilter = { - bool: { - filter: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], - }, - }; - - const cgroupMemoryFilter = { - exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, - }; - - const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; - - function withTimeseries(agg: T) { - return { - avg: { avg: agg }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, - }, - }, - aggs: { - avg: { avg: agg }, - }, - }, - }; - } - - const subAggs = { - memory_usage_cgroup: { - filter: cgroupMemoryFilter, - aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), - }, - memory_usage_system: { - filter: systemMemoryFilter, - aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), - }, - cpu_usage: { - filter: cpuUsageFilter, - aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), - }, - }; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ], - should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], - minimum_should_match: 1, - }, - }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, - }, - aggs: subAggs, - }, - }, - }, - }); - - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const hasCGroupData = - serviceNodeBucket.memory_usage_cgroup.avg.value !== null; - - const memoryMetricsKey = hasCGroupData - ? 'memory_usage_cgroup' - : 'memory_usage_system'; - - return { - serviceNodeName: String(serviceNodeBucket.key), - cpuUsage: { - value: serviceNodeBucket.cpu_usage.avg.value, - timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg.value, - }) - ), - }, - memoryUsage: { - value: serviceNodeBucket[memoryMetricsKey].avg.value, - timeseries: serviceNodeBucket[ - memoryMetricsKey - ].timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg.value, - })), - }, - }; - } - ) ?? [] - ); - }); -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts deleted file mode 100644 index 94a5e54e9ace5..0000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts +++ /dev/null @@ -1,166 +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 { EventOutcome } from '../../../../common/event_outcome'; -import { - environmentQuery, - rangeQuery, - kqlQuery, -} from '../../../../server/utils/queries'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; -import { - EVENT_OUTCOME, - SERVICE_NAME, - SERVICE_NODE_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; -import { ServiceInstanceParams } from '.'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { calculateThroughput } from '../../helpers/calculate_throughput'; -import { withApmSpan } from '../../../utils/with_apm_span'; -import { - getLatencyAggregation, - getLatencyValue, -} from '../../helpers/latency_aggregation_type'; - -export async function getServiceInstanceTransactionStats({ - environment, - kuery, - latencyAggregationType, - setup, - transactionType, - serviceName, - size, - searchAggregatedTransactions, - numBuckets, -}: ServiceInstanceParams) { - return withApmSpan('get_service_instance_transaction_stats', async () => { - const { apmEventClient, start, end } = setup; - - const { intervalString, bucketSize } = getBucketSize({ - start, - end, - numBuckets, - }); - - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - - const subAggs = { - ...getLatencyAggregation(latencyAggregationType, field), - failures: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, - }, - }, - }, - }; - - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - ...rangeQuery(start, end), - ...environmentQuery(environment), - ...kqlQuery(kuery), - ], - }, - }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, - }, - aggs: { - ...subAggs, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, - }, - }, - aggs: { - ...subAggs, - }, - }, - }, - }, - }, - }, - }); - - const bucketSizeInMinutes = bucketSize / 60; - - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const { - doc_count: count, - latency, - key, - failures, - timeseries, - } = serviceNodeBucket; - - return { - serviceNodeName: String(key), - errorRate: { - value: failures.doc_count / count, - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.failures.doc_count / dateBucket.doc_count, - })), - }, - throughput: { - value: calculateThroughput({ start, end, value: count }), - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.doc_count / bucketSizeInMinutes, - })), - }, - latency: { - value: getLatencyValue({ - aggregation: latency, - latencyAggregationType, - }), - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: getLatencyValue({ - aggregation: dateBucket.latency, - latencyAggregationType, - }), - })), - }, - }; - } - ) ?? [] - ); - }); -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_system_metric_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_system_metric_statistics.ts new file mode 100644 index 0000000000000..1a33e9810dd5e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_system_metric_statistics.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AggregationOptionsByType } from 'typings/elasticsearch'; +import { + METRIC_CGROUP_MEMORY_USAGE_BYTES, + METRIC_PROCESS_CPU_PERCENT, + METRIC_SYSTEM_FREE_MEMORY, + METRIC_SYSTEM_TOTAL_MEMORY, + SERVICE_NAME, + SERVICE_NODE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { Coordinate } from '../../../../typings/timeseries'; +import { environmentQuery, kqlQuery, rangeQuery } from '../../../utils/queries'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { Setup } from '../../helpers/setup_request'; +import { + percentCgroupMemoryUsedScript, + percentSystemMemoryUsedScript, +} from '../../metrics/by_agent/shared/memory'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +interface ServiceInstanceSystemMetricPrimaryStatistics { + serviceNodeName: string; + cpuUsage: number | null; + memoryUsage: number | null; +} + +interface ServiceInstanceSystemMetricComparisonStatistics { + serviceNodeName: string; + cpuUsage: Coordinate[]; + memoryUsage: Coordinate[]; +} + +type ServiceInstanceSystemMetricStatistics = T extends true + ? ServiceInstanceSystemMetricComparisonStatistics + : ServiceInstanceSystemMetricPrimaryStatistics; + +export async function getServiceInstancesSystemMetricStatistics< + T extends true | false +>({ + environment, + kuery, + setup, + serviceName, + size, + start, + end, + serviceNodeIds, + numBuckets, + isComparisonSearch, +}: { + setup: Setup; + serviceName: string; + start: number; + end: number; + numBuckets?: number; + serviceNodeIds?: string[]; + environment?: string; + kuery?: string; + size?: number; + isComparisonSearch: T; +}): Promise>> { + return withApmSpan( + 'get_service_instances_system_metric_statistics', + async () => { + const { apmEventClient } = setup; + + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const systemMemoryFilter = { + bool: { + filter: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }, + }; + + const cgroupMemoryFilter = { + exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, + }; + + const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; + + function withTimeseries( + agg: TParams + ) { + return { + ...(isComparisonSearch + ? { + avg: { avg: agg }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { avg: { avg: agg } }, + }, + } + : { avg: { avg: agg } }), + }; + } + + const subAggs = { + memory_usage_cgroup: { + filter: cgroupMemoryFilter, + aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), + }, + memory_usage_system: { + filter: systemMemoryFilter, + aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), + }, + cpu_usage: { + filter: cpuUsageFilter, + aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), + }, + }; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...(isComparisonSearch && serviceNodeIds + ? [{ terms: { [SERVICE_NODE_NAME]: serviceNodeIds } }] + : []), + ], + should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], + minimum_should_match: 1, + }, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + ...(size ? { size } : {}), + ...(isComparisonSearch ? { include: serviceNodeIds } : {}), + }, + aggs: subAggs, + }, + }, + }, + }); + + return ( + (response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const serviceNodeName = String(serviceNodeBucket.key); + const hasCGroupData = + serviceNodeBucket.memory_usage_cgroup.avg.value !== null; + + const memoryMetricsKey = hasCGroupData + ? 'memory_usage_cgroup' + : 'memory_usage_system'; + + const cpuUsage = + // Timeseries is available when isComparisonSearch is true + 'timeseries' in serviceNodeBucket.cpu_usage + ? serviceNodeBucket.cpu_usage.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + }) + ) + : serviceNodeBucket.cpu_usage.avg.value; + + const memoryUsageValue = serviceNodeBucket[memoryMetricsKey]; + const memoryUsage = + // Timeseries is available when isComparisonSearch is true + 'timeseries' in memoryUsageValue + ? memoryUsageValue.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + })) + : serviceNodeBucket[memoryMetricsKey].avg.value; + + return { + serviceNodeName, + cpuUsage, + memoryUsage, + }; + } + ) as Array>) || [] + ); + } + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts new file mode 100644 index 0000000000000..ad54a231b52ef --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instances_transaction_statistics.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + EVENT_OUTCOME, + SERVICE_NAME, + SERVICE_NODE_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../../common/event_outcome'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { Coordinate } from '../../../../typings/timeseries'; +import { environmentQuery, kqlQuery, rangeQuery } from '../../../utils/queries'; +import { + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../../helpers/aggregated_transactions'; +import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../../helpers/latency_aggregation_type'; +import { Setup } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +interface ServiceInstanceTransactionPrimaryStatistics { + serviceNodeName: string; + errorRate: number; + latency: number; + throughput: number; +} + +interface ServiceInstanceTransactionComparisonStatistics { + serviceNodeName: string; + errorRate: Coordinate[]; + latency: Coordinate[]; + throughput: Coordinate[]; +} + +type ServiceInstanceTransactionStatistics = T extends true + ? ServiceInstanceTransactionComparisonStatistics + : ServiceInstanceTransactionPrimaryStatistics; + +export async function getServiceInstancesTransactionStatistics< + T extends true | false +>({ + environment, + kuery, + latencyAggregationType, + setup, + transactionType, + serviceName, + size, + searchAggregatedTransactions, + start, + end, + serviceNodeIds, + numBuckets, + isComparisonSearch, +}: { + latencyAggregationType: LatencyAggregationType; + setup: Setup; + serviceName: string; + transactionType: string; + searchAggregatedTransactions: boolean; + start: number; + end: number; + isComparisonSearch: T; + serviceNodeIds?: string[]; + environment?: string; + kuery?: string; + size?: number; + numBuckets?: number; +}): Promise>> { + return withApmSpan( + 'get_service_instances_transaction_statistics', + async () => { + const { apmEventClient } = setup; + + const { intervalString, bucketSize } = getBucketSize({ + start, + end, + numBuckets, + }); + + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + + const subAggs = { + ...getLatencyAggregation(latencyAggregationType, field), + failures: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, + }, + }, + }; + + const query = { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...(isComparisonSearch && serviceNodeIds + ? [{ terms: { [SERVICE_NODE_NAME]: serviceNodeIds } }] + : []), + ], + }, + }; + + const aggs = { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + ...(size ? { size } : {}), + ...(isComparisonSearch ? { include: serviceNodeIds } : {}), + }, + aggs: isComparisonSearch + ? { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: subAggs, + }, + } + : subAggs, + }, + }; + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { size: 0, query, aggs }, + }); + + const bucketSizeInMinutes = bucketSize / 60; + + return ( + (response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const { doc_count: count, key } = serviceNodeBucket; + const serviceNodeName = String(key); + + // Timeseries is returned when isComparisonSearch is true + if ('timeseries' in serviceNodeBucket) { + const { timeseries } = serviceNodeBucket; + return { + serviceNodeName, + errorRate: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.failures.doc_count / dateBucket.doc_count, + })), + throughput: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count / bucketSizeInMinutes, + })), + latency: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: getLatencyValue({ + aggregation: dateBucket.latency, + latencyAggregationType, + }), + })), + }; + } else { + const { failures, latency } = serviceNodeBucket; + return { + serviceNodeName, + errorRate: failures.doc_count / count, + latency: getLatencyValue({ + aggregation: latency, + latencyAggregationType, + }), + throughput: calculateThroughput({ start, end, value: count }), + }; + } + } + ) as Array>) || [] + ); + } + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts similarity index 52% rename from x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts rename to x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts index 838753890a8cd..3cd98558eff02 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/primary_statistics.ts @@ -9,10 +9,10 @@ import { LatencyAggregationType } from '../../../../common/latency_aggregation_t import { joinByKey } from '../../../../common/utils/join_by_key'; import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats'; -import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats'; +import { getServiceInstancesSystemMetricStatistics } from './get_service_instances_system_metric_statistics'; +import { getServiceInstancesTransactionStatistics } from './get_service_instances_transaction_statistics'; -export interface ServiceInstanceParams { +interface ServiceInstancePrimaryStatisticsParams { environment?: string; kuery?: string; latencyAggregationType: LatencyAggregationType; @@ -21,21 +21,37 @@ export interface ServiceInstanceParams { transactionType: string; searchAggregatedTransactions: boolean; size: number; - numBuckets: number; + start: number; + end: number; } -export async function getServiceInstances( - params: Omit -) { - return withApmSpan('get_service_instances', async () => { +export async function getServiceInstancesPrimaryStatistics( + params: Omit +): Promise< + Array<{ + serviceNodeName: string; + errorRate?: number; + latency?: number; + throughput?: number; + cpuUsage?: number | null; + memoryUsage?: number | null; + }> +> { + return withApmSpan('get_service_instances_primary_statistics', async () => { const paramsForSubQueries = { ...params, size: 50, }; const [transactionStats, systemMetricStats] = await Promise.all([ - getServiceInstanceTransactionStats(paramsForSubQueries), - getServiceInstanceSystemMetricStats(paramsForSubQueries), + getServiceInstancesTransactionStatistics({ + ...paramsForSubQueries, + isComparisonSearch: false, + }), + getServiceInstancesSystemMetricStatistics({ + ...paramsForSubQueries, + isComparisonSearch: false, + }), ]); const stats = joinByKey( diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts index a71772d1429cb..e2341b306a878 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -6,7 +6,6 @@ */ import { ProcessorEvent } from '../../../common/processor_event'; -import { SortOptions } from '../../../../../../typings/elasticsearch'; import { AGENT, CLOUD, @@ -96,7 +95,7 @@ export function getServiceMetadataDetails({ terms: { field: SERVICE_VERSION, size: 10, - order: { _key: 'desc' } as SortOptions, + order: { _key: 'desc' as const }, }, }, availabilityZones: { diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 10c7420d0f3b0..1e36df379e964 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -108,9 +108,9 @@ export async function getServiceTransactionStats({ }, sample: { top_metrics: { - metrics: { field: AGENT_NAME } as const, + metrics: [{ field: AGENT_NAME } as const], sort: { - '@timestamp': 'desc', + '@timestamp': 'desc' as const, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts index cabd44c1e6907..906cc62e64d1a 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts @@ -59,7 +59,7 @@ export function getServicesFromMetricDocuments({ }, latest: { top_metrics: { - metrics: { field: AGENT_NAME } as const, + metrics: [{ field: AGENT_NAME } as const], sort: { '@timestamp': 'desc' }, }, }, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts index 2d6eff33b5b4e..0b826ea10b6c4 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { ESSearchHit } from '../../../../../../../typings/elasticsearch'; +import { SearchHit } from '../../../../../../../typings/elasticsearch'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; // needed for backwards compatability // All settings except `transaction_sample_rate` and `transaction_max_spans` are stored as strings (they are stored as float and integer respectively) export function convertConfigSettingsToString( - hit: ESSearchHit + hit: SearchHit ) { const config = hit._source; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index 4f2225e3cec18..7ec850717dab1 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -8,7 +8,7 @@ import { ElasticsearchClient, Logger } from 'src/core/server'; import { createOrUpdateIndex, - MappingsDefinition, + Mappings, } from '../../../../../observability/server'; import { APMConfig } from '../../..'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; @@ -31,7 +31,7 @@ export async function createApmAgentConfigurationIndex({ }); } -const mappings: MappingsDefinition = { +const mappings: Mappings = { dynamic: 'strict', dynamic_templates: [ { diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts index 972c076d88e76..9fd4849c7640a 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ESSearchHit } from '../../../../../../../typings/elasticsearch'; +import { SearchHit } from '../../../../../../../typings/elasticsearch'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { SERVICE_ENVIRONMENT, @@ -46,9 +46,7 @@ export function findExactConfiguration({ params ); - const hit = resp.hits.hits[0] as - | ESSearchHit - | undefined; + const hit = resp.hits.hits[0] as SearchHit | undefined; if (!hit) { return; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts index 12ba0939508e3..7454128a741d5 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ESSearchHit } from '../../../../../../../typings/elasticsearch'; +import { SearchHit } from '../../../../../../../typings/elasticsearch'; import { SERVICE_NAME, SERVICE_ENVIRONMENT, @@ -75,9 +75,7 @@ export async function searchConfigurations({ params ); - const hit = resp.hits.hits[0] as - | ESSearchHit - | undefined; + const hit = resp.hits.hits[0] as SearchHit | undefined; if (!hit) { return; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index 037f54344d770..3965e363499fc 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -6,9 +6,10 @@ */ import { ElasticsearchClient, Logger } from 'src/core/server'; +import { PropertyBase } from '@elastic/elasticsearch/api/types'; import { createOrUpdateIndex, - MappingsDefinition, + Mappings, } from '../../../../../observability/server'; import { APMConfig } from '../../..'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; @@ -31,7 +32,7 @@ export const createApmCustomLinkIndex = async ({ }); }; -const mappings: MappingsDefinition = { +const mappings: Mappings = { dynamic: 'strict', properties: { '@timestamp': { @@ -45,7 +46,8 @@ const mappings: MappingsDefinition = { type: 'keyword', }, }, - }, + // FIXME: PropertyBase type is missing .fields + } as PropertyBase, url: { type: 'keyword', }, diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts index 8e343ecfe6a64..d3d9b45285354 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts @@ -6,6 +6,7 @@ */ import * as t from 'io-ts'; +import { compact } from 'lodash'; import { Setup } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; import { filterOptionsRt } from './custom_link_types'; @@ -22,15 +23,15 @@ export function getTransaction({ return withApmSpan('get_transaction_for_custom_link', async () => { const { apmEventClient } = setup; - const esFilters = Object.entries(filters) - // loops through the filters splitting the value by comma and removing white spaces - .map(([key, value]) => { - if (value) { - return { terms: { [key]: splitFilterValueByComma(value) } }; - } - }) - // removes filters without value - .filter((value) => value); + const esFilters = compact( + Object.entries(filters) + // loops through the filters splitting the value by comma and removing white spaces + .map(([key, value]) => { + if (value) { + return { terms: { [key]: splitFilterValueByComma(value) } }; + } + }) + ); const params = { terminateAfter: 1, diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts index 7437b8328b876..f6b41f462c99f 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -6,6 +6,7 @@ */ import * as t from 'io-ts'; +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { CustomLink, CustomLinkES, @@ -31,7 +32,7 @@ export function listCustomLinks({ should: [ { term: { [key]: value } }, { bool: { must_not: [{ exists: { field: key } }] } }, - ], + ] as QueryContainer[], }, }; }); @@ -48,7 +49,7 @@ export function listCustomLinks({ sort: [ { 'label.keyword': { - order: 'asc', + order: 'asc' as const, }, }, ], diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts index 0b158d9e57285..a946fa66a3b92 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { ProcessorEvent } from '../../../common/processor_event'; import { TRACE_ID, @@ -75,7 +76,7 @@ export async function getTraceItems( filter: [ { term: { [TRACE_ID]: traceId } }, ...rangeQuery(start, end), - ], + ] as QueryContainer[], should: { exists: { field: PARENT_ID }, }, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index 34c2f39ca04c0..a4ff487645a4b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -14,9 +14,11 @@ Array [ "aggs": Object { "transaction_type": Object { "top_metrics": Object { - "metrics": Object { - "field": "transaction.type", - }, + "metrics": Array [ + Object { + "field": "transaction.type", + }, + ], "sort": Object { "@timestamp": "desc", }, @@ -210,9 +212,11 @@ Array [ "aggs": Object { "transaction_type": Object { "top_metrics": Object { - "metrics": Object { - "field": "transaction.type", - }, + "metrics": Array [ + Object { + "field": "transaction.type", + }, + ], "sort": Object { "@timestamp": "desc", }, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 6308236000a53..c1bf363b49d1c 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -8,6 +8,7 @@ import { sortBy, take } from 'lodash'; import moment from 'moment'; import { Unionize } from 'utility-types'; +import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { @@ -132,14 +133,14 @@ export function transactionGroupsFetcher( ...(isTopTraces ? { composite: { - sources: [ + sources: asMutableArray([ { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, { [TRANSACTION_NAME]: { terms: { field: TRANSACTION_NAME }, }, }, - ], + ] as const), size, }, } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts index 5409f919bf895..86be82faee578 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts @@ -6,9 +6,9 @@ */ import { merge } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; import { TRANSACTION_TYPE } from '../../../common/elasticsearch_fieldnames'; import { arrayUnionToCallable } from '../../../common/utils/array_union_to_callable'; -import { AggregationInputMap } from '../../../../../../typings/elasticsearch'; import { TransactionGroupRequestBase, TransactionGroupSetup } from './fetcher'; import { getTransactionDurationFieldForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { withApmSpan } from '../../utils/with_apm_span'; @@ -23,8 +23,8 @@ type BucketKey = string | Record; function mergeRequestWithAggs< TRequestBase extends TransactionGroupRequestBase, - TInputMap extends AggregationInputMap ->(request: TRequestBase, aggs: TInputMap) { + TAggregationMap extends Record +>(request: TRequestBase, aggs: TAggregationMap) { return merge({}, request, { body: { aggs: { @@ -71,11 +71,13 @@ export function getCounts({ request, setup }: MetricParams) { transaction_type: { top_metrics: { sort: { - '@timestamp': 'desc', + '@timestamp': 'desc' as const, }, - metrics: { - field: TRANSACTION_TYPE, - } as const, + metrics: [ + { + field: TRANSACTION_TYPE, + } as const, + ], }, }, }); diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts index 1771b5ead68a7..1a586d1d4dbb6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { withApmSpan } from '../../../../utils/with_apm_span'; import { SERVICE_NAME, @@ -86,7 +86,7 @@ export async function getBuckets({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), - ]; + ] as QueryContainer[]; async function getSamplesForDistributionBuckets() { const response = await withApmSpan( @@ -106,7 +106,7 @@ export async function getBuckets({ should: [ { term: { [TRACE_ID]: traceId } }, { term: { [TRANSACTION_ID]: transactionId } }, - ], + ] as QueryContainer[], }, }, aggs: { @@ -122,7 +122,7 @@ export async function getBuckets({ _source: [TRANSACTION_ID, TRACE_ID], size: 10, sort: { - _score: 'desc', + _score: 'desc' as const, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts index a35780539a256..8b068fd6bd2fb 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { ESSearchResponse } from '../../../../../../../typings/elasticsearch'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { rangeQuery } from '../../../../server/utils/queries'; +import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; @@ -42,7 +44,7 @@ export function anomalySeriesFetcher({ { term: { partition_field_value: serviceName } }, { term: { by_field_value: transactionType } }, ...rangeQuery(start, end, 'timestamp'), - ], + ] as QueryContainer[], }, }, aggs: { @@ -60,11 +62,11 @@ export function anomalySeriesFetcher({ aggs: { anomaly_score: { top_metrics: { - metrics: [ + metrics: asMutableArray([ { field: 'record_score' }, { field: 'timestamp' }, { field: 'bucket_span' }, - ] as const, + ] as const), sort: { record_score: 'desc' as const, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts index a089850e427e6..b4323ae7f51e2 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts @@ -12,6 +12,7 @@ import { import { rangeQuery } from '../../../../server/utils/queries'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { withApmSpan } from '../../../utils/with_apm_span'; export function getTransaction({ @@ -34,11 +35,11 @@ export function getTransaction({ size: 1, query: { bool: { - filter: [ + filter: asMutableArray([ { term: { [TRANSACTION_ID]: transactionId } }, { term: { [TRACE_ID]: traceId } }, ...rangeQuery(start, end), - ], + ]), }, }, }, diff --git a/x-pack/plugins/apm/server/projections/metrics.ts b/x-pack/plugins/apm/server/projections/metrics.ts index ca43d0a8fb3c8..68056f091c873 100644 --- a/x-pack/plugins/apm/server/projections/metrics.ts +++ b/x-pack/plugins/apm/server/projections/metrics.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { Setup, SetupTimeRange } from '../../server/lib/helpers/setup_request'; import { SERVICE_NAME, @@ -51,7 +52,7 @@ export function getMetricsProjection({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), - ]; + ] as QueryContainer[]; return { apm: { diff --git a/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts b/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts index 9c6ea6bc83511..29fc85128ff3f 100644 --- a/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts +++ b/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @@ -46,9 +46,7 @@ export function getRumPageLoadTransactionsProjection({ ? [ { wildcard: { - 'url.full': { - value: `*${urlQuery}*`, - }, + 'url.full': `*${urlQuery}*`, }, }, ] @@ -92,9 +90,7 @@ export function getRumErrorsProjection({ ? [ { wildcard: { - 'url.full': { - value: `*${urlQuery}*`, - }, + 'url.full': `*${urlQuery}*`, }, }, ] diff --git a/x-pack/plugins/apm/server/projections/transactions.ts b/x-pack/plugins/apm/server/projections/transactions.ts index 7955518d56f03..dd16b0b910abf 100644 --- a/x-pack/plugins/apm/server/projections/transactions.ts +++ b/x-pack/plugins/apm/server/projections/transactions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { QueryContainer } from '@elastic/elasticsearch/api/types'; import { Setup, SetupTimeRange } from '../../server/lib/helpers/setup_request'; import { SERVICE_NAME, @@ -61,7 +62,7 @@ export function getTransactionsProjection({ ...rangeQuery(start, end), ...environmentQuery(environment), ...kqlQuery(kuery), - ], + ] as QueryContainer[], }; return { diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts index 558f165d43cf5..bb90aa0bf5eb4 100644 --- a/x-pack/plugins/apm/server/projections/typings.ts +++ b/x-pack/plugins/apm/server/projections/typings.ts @@ -4,20 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { - AggregationOptionsByType, - AggregationInputMap, - ESSearchBody, -} from '../../../../../typings/elasticsearch'; +import { estypes } from '@elastic/elasticsearch'; +import { AggregationOptionsByType } from '../../../../../typings/elasticsearch'; import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_apm_event_client'; export type Projection = Omit & { - body: Omit & { + body: Omit< + Required['body'], + 'aggs' | 'aggregations' + > & { aggs?: { [key: string]: { terms: AggregationOptionsByType['terms'] & { field: string }; - aggs?: AggregationInputMap; + aggs?: Record; }; }; }; diff --git a/x-pack/plugins/apm/server/projections/util/merge_projection/index.test.ts b/x-pack/plugins/apm/server/projections/util/merge_projection/index.test.ts index c38859056afeb..16b2c6f8e6061 100644 --- a/x-pack/plugins/apm/server/projections/util/merge_projection/index.test.ts +++ b/x-pack/plugins/apm/server/projections/util/merge_projection/index.test.ts @@ -13,11 +13,11 @@ describe('mergeProjection', () => { mergeProjection( { apm: { events: [] }, - body: { query: { bool: { must: [{ terms: ['a'] }] } } }, + body: { query: { bool: { must: [{ terms: { field: ['a'] } }] } } }, }, { apm: { events: [] }, - body: { query: { bool: { must: [{ term: 'b' }] } } }, + body: { query: { bool: { must: [{ term: { field: 'b' } }] } } }, } ) ).toEqual({ @@ -29,7 +29,7 @@ describe('mergeProjection', () => { bool: { must: [ { - term: 'b', + term: { field: 'b' }, }, ], }, diff --git a/x-pack/plugins/apm/server/projections/util/merge_projection/index.ts b/x-pack/plugins/apm/server/projections/util/merge_projection/index.ts index 7f08707064862..fb2d981dd4a1f 100644 --- a/x-pack/plugins/apm/server/projections/util/merge_projection/index.ts +++ b/x-pack/plugins/apm/server/projections/util/merge_projection/index.ts @@ -7,20 +7,12 @@ import { cloneDeep, isPlainObject, mergeWith } from 'lodash'; import { DeepPartial } from 'utility-types'; -import { - AggregationInputMap, - ESSearchBody, -} from '../../../../../../../typings/elasticsearch'; import { APMEventESSearchRequest } from '../../../lib/helpers/create_es_client/create_apm_event_client'; import { Projection } from '../../typings'; type PlainObject = Record; -type SourceProjection = Omit, 'body'> & { - body: Omit, 'aggs'> & { - aggs?: AggregationInputMap; - }; -}; +type SourceProjection = DeepPartial; type DeepMerge = U extends PlainObject ? T extends PlainObject diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts index 32f2238b0ddea..3bebcd49ec34a 100644 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -35,12 +35,14 @@ export const transactionErrorRateChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; + const { _inspect, ...alertParams } = context.params.query; - return getTransactionErrorRateChartPreview({ + const errorRateChartPreview = await getTransactionErrorRateChartPreview({ setup, alertParams, }); + + return { errorRateChartPreview }; }, }); @@ -50,11 +52,13 @@ export const transactionErrorCountChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; - return getTransactionErrorCountChartPreview({ + const { _inspect, ...alertParams } = context.params.query; + const errorCountChartPreview = await getTransactionErrorCountChartPreview({ setup, alertParams, }); + + return { errorCountChartPreview }; }, }); @@ -64,11 +68,13 @@ export const transactionDurationChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; + const { _inspect, ...alertParams } = context.params.query; - return getTransactionDurationChartPreview({ + const latencyChartPreview = await getTransactionDurationChartPreview({ alertParams, setup, }); + + return { latencyChartPreview }; }, }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 01d2797641805..9958b8dec0124 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -48,6 +48,49 @@ const getCoreMock = () => { }; }; +const initApi = (params?: RouteParamsRT) => { + const { mock, context, createRouter, get, post } = getCoreMock(); + const handlerMock = jest.fn(); + createApi() + .add(() => ({ + endpoint: 'GET /foo', + params, + options: { tags: ['access:apm'] }, + handler: handlerMock, + })) + .init(mock, context); + + const routeHandler = get.mock.calls[0][1]; + + const responseMock = { + ok: jest.fn(), + custom: jest.fn(), + }; + + const simulateRequest = (requestMock: any) => { + return routeHandler( + {}, + { + // stub default values + params: {}, + query: {}, + body: null, + ...requestMock, + }, + responseMock + ); + }; + + return { + simulateRequest, + handlerMock, + createRouter, + get, + post, + responseMock, + }; +}; + describe('createApi', () => { it('registers a route with the server', () => { const { mock, context, createRouter, post, get, put } = getCoreMock(); @@ -56,7 +99,7 @@ describe('createApi', () => { .add(() => ({ endpoint: 'GET /foo', options: { tags: ['access:apm'] }, - handler: async () => null, + handler: async () => ({}), })) .add(() => ({ endpoint: 'POST /bar', @@ -64,21 +107,21 @@ describe('createApi', () => { body: t.string, }), options: { tags: ['access:apm'] }, - handler: async () => null, + handler: async () => ({}), })) .add(() => ({ endpoint: 'PUT /baz', options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async () => null, + handler: async () => ({}), })) .add({ endpoint: 'GET /qux', options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async () => null, + handler: async () => ({}), }) .init(mock, context); @@ -122,102 +165,78 @@ describe('createApi', () => { }); describe('when validating', () => { - const initApi = (params?: RouteParamsRT) => { - const { mock, context, createRouter, get, post } = getCoreMock(); - const handlerMock = jest.fn(); - createApi() - .add(() => ({ - endpoint: 'GET /foo', - params, - options: { tags: ['access:apm'] }, - handler: handlerMock, - })) - .init(mock, context); - - const routeHandler = get.mock.calls[0][1]; - - const responseMock = { - ok: jest.fn(), - internalError: jest.fn(), - notFound: jest.fn(), - forbidden: jest.fn(), - badRequest: jest.fn(), - }; - - const simulate = (requestMock: any) => { - return routeHandler( - {}, - { - // stub default values - params: {}, - query: {}, - body: null, - ...requestMock, - }, - responseMock - ); - }; - - return { simulate, handlerMock, createRouter, get, post, responseMock }; - }; - - it('adds a _debug query parameter by default', async () => { - const { simulate, handlerMock, responseMock } = initApi(); - - await simulate({ query: { _debug: 'true' } }); + describe('_inspect', () => { + it('allows _inspect=true', async () => { + const { simulateRequest, handlerMock, responseMock } = initApi(); + await simulateRequest({ query: { _inspect: 'true' } }); + + const params = handlerMock.mock.calls[0][0].context.params; + expect(params).toEqual({ query: { _inspect: true } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + + // responds with ok + expect(responseMock.custom).not.toHaveBeenCalled(); + expect(responseMock.ok).toHaveBeenCalledWith({ + body: { _inspect: [] }, + }); + }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + it('rejects _inspect=1', async () => { + const { simulateRequest, responseMock } = initApi(); + await simulateRequest({ query: { _inspect: 1 } }); + + // responds with error handler + expect(responseMock.ok).not.toHaveBeenCalled(); + expect(responseMock.custom).toHaveBeenCalledWith({ + body: { + attributes: { _inspect: [] }, + message: + 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', + }, + statusCode: 400, + }); + }); - expect(handlerMock).toHaveBeenCalledTimes(1); + it('allows omitting _inspect', async () => { + const { simulateRequest, handlerMock, responseMock } = initApi(); + await simulateRequest({ query: {} }); - expect(responseMock.ok).toHaveBeenCalled(); + const params = handlerMock.mock.calls[0][0].context.params; + expect(params).toEqual({ query: { _inspect: false } }); + expect(handlerMock).toHaveBeenCalledTimes(1); - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - query: { - _debug: true, - }, + // responds with ok + expect(responseMock.custom).not.toHaveBeenCalled(); + expect(responseMock.ok).toHaveBeenCalledWith({ body: {} }); }); - - await simulate({ - query: { - _debug: 1, - }, - }); - - expect(responseMock.badRequest).toHaveBeenCalled(); }); - it('throws if any parameters are used but no types are defined', async () => { - const { simulate, responseMock } = initApi(); + it('throws if unknown parameters are provided', async () => { + const { simulateRequest, responseMock } = initApi(); - await simulate({ - query: { - _debug: true, - extra: '', - }, + await simulateRequest({ + query: { _inspect: true, extra: '' }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); - await simulate({ + await simulateRequest({ body: { foo: 'bar' }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(2); + expect(responseMock.custom).toHaveBeenCalledTimes(2); - await simulate({ + await simulateRequest({ params: { foo: 'bar', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(3); + expect(responseMock.custom).toHaveBeenCalledTimes(3); }); it('validates path parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ path: t.type({ foo: t.string, @@ -225,7 +244,7 @@ describe('createApi', () => { }) ); - await simulate({ + await simulateRequest({ params: { foo: 'bar', }, @@ -234,7 +253,7 @@ describe('createApi', () => { expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); const params = handlerMock.mock.calls[0][0].context.params; @@ -243,48 +262,48 @@ describe('createApi', () => { foo: 'bar', }, query: { - _debug: false, + _inspect: false, }, }); - await simulate({ + await simulateRequest({ params: { bar: 'foo', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); - await simulate({ + await simulateRequest({ params: { foo: 9, }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(2); + expect(responseMock.custom).toHaveBeenCalledTimes(2); - await simulate({ + await simulateRequest({ params: { foo: 'bar', extra: '', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(3); + expect(responseMock.custom).toHaveBeenCalledTimes(3); }); it('validates body parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ body: t.string, }) ); - await simulate({ + await simulateRequest({ body: '', }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); @@ -293,19 +312,19 @@ describe('createApi', () => { expect(params).toEqual({ body: '', query: { - _debug: false, + _inspect: false, }, }); - await simulate({ + await simulateRequest({ body: null, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); }); it('validates query parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ query: t.type({ bar: t.string, @@ -314,15 +333,15 @@ describe('createApi', () => { }) ); - await simulate({ + await simulateRequest({ query: { bar: '', - _debug: 'true', + _inspect: 'true', filterNames: JSON.stringify(['hostName', 'agentName']), }, }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); @@ -331,19 +350,19 @@ describe('createApi', () => { expect(params).toEqual({ query: { bar: '', - _debug: true, + _inspect: true, filterNames: ['hostName', 'agentName'], }, }); - await simulate({ + await simulateRequest({ query: { bar: '', foo: '', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 46f2628cc73d5..13e70a2043cf0 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -11,19 +11,20 @@ import { schema } from '@kbn/config-schema'; import * as t from 'io-ts'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaResponseFactory, RouteRegistrar } from 'src/core/server'; +import { KibanaRequest, RouteRegistrar } from 'src/core/server'; import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; import agent from 'elastic-apm-node'; +import { parseMethod } from '../../../common/apm_api/parse_endpoint'; import { merge } from '../../../common/runtime_types/merge'; import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; import { APMConfig } from '../..'; -import { ServerAPI } from '../typings'; +import { InspectResponse, RouteParamsRT, ServerAPI } from '../typings'; import { jsonRt } from '../../../common/runtime_types/json_rt'; import type { ApmPluginRequestHandlerContext } from '../typings'; -const debugRt = t.exact( +const inspectRt = t.exact( t.partial({ - query: t.exact(t.partial({ _debug: jsonRt.pipe(t.boolean) })), + query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), }) ); @@ -32,6 +33,11 @@ type RouteOrRouteFactoryFn = Parameters['add']>[0]; const isNotEmpty = (val: any) => val !== undefined && val !== null && !(isPlainObject(val) && isEmpty(val)); +export const inspectableEsQueriesMap = new WeakMap< + KibanaRequest, + InspectResponse +>(); + export function createApi() { const routes: RouteOrRouteFactoryFn[] = []; const api: ServerAPI<{}> = { @@ -58,24 +64,10 @@ export function createApi() { const { params, endpoint, options, handler } = route; const [method, path] = endpoint.split(' '); - - const typedRouterMethod = method.trim().toLowerCase() as - | 'get' - | 'post' - | 'put' - | 'delete'; - - if (!['get', 'post', 'put', 'delete'].includes(typedRouterMethod)) { - throw new Error( - "Couldn't register route, as endpoint was not prefixed with a valid HTTP method" - ); - } + const typedRouterMethod = parseMethod(method); // For all runtime types with props, we create an exact // version that will strip all keys that are unvalidated. - - const paramsRt = params ? merge([params, debugRt]) : debugRt; - const anyObject = schema.object({}, { unknowns: 'allow' }); (router[typedRouterMethod] as RouteRegistrar< @@ -102,56 +94,52 @@ export function createApi() { }); } - try { - const paramMap = pickBy( - { - path: request.params, - body: request.body, - query: { - _debug: 'false', - ...request.query, - }, - }, - isNotEmpty - ); - - const result = strictKeysRt(paramsRt).decode(paramMap); + // init debug queries + inspectableEsQueriesMap.set(request, []); - if (isLeft(result)) { - throw Boom.badRequest(PathReporter.report(result)[0]); - } + try { + const validParams = validateParams(request, params); const data = await handler({ request, context: { ...context, plugins, - // Only return values for parameters that have runtime types, - // but always include query as _debug is always set even if - // it's not defined in the route. - params: mergeLodash( - { query: { _debug: false } }, - pickBy(result.right, isNotEmpty) - ), + params: validParams, config, logger, }, }); - return response.ok({ body: data as any }); + const body = { ...data }; + if (validParams.query._inspect) { + body._inspect = inspectableEsQueriesMap.get(request); + } + + // cleanup + inspectableEsQueriesMap.delete(request); + + return response.ok({ body }); } catch (error) { + const opts = { + statusCode: 500, + body: { + message: error.message, + attributes: { + _inspect: inspectableEsQueriesMap.get(request), + }, + }, + }; + if (Boom.isBoom(error)) { - return convertBoomToKibanaResponse(error, response); + opts.statusCode = error.output.statusCode; } if (error instanceof RequestAbortedError) { - return response.custom({ - statusCode: 499, - body: { - message: 'Client closed request', - }, - }); + opts.statusCode = 499; + opts.body.message = 'Client closed request'; } - throw error; + + return response.custom(opts); } } ); @@ -162,22 +150,35 @@ export function createApi() { return api; } -function convertBoomToKibanaResponse( - error: Boom.Boom, - response: KibanaResponseFactory +function validateParams( + request: KibanaRequest, + params: RouteParamsRT | undefined ) { - const opts = { body: { message: error.message } }; - switch (error.output.statusCode) { - case 404: - return response.notFound(opts); - - case 400: - return response.badRequest(opts); + const paramsRt = params ? merge([params, inspectRt]) : inspectRt; + const paramMap = pickBy( + { + path: request.params, + body: request.body, + query: { + _inspect: 'false', + // @ts-ignore + ...request.query, + }, + }, + isNotEmpty + ); - case 403: - return response.forbidden(opts); + const result = strictKeysRt(paramsRt).decode(paramMap); - default: - throw error; + if (isLeft(result)) { + throw Boom.badRequest(PathReporter.report(result)[0]); } + + // Only return values for parameters that have runtime types, + // but always include query as _inspect is always set even if + // it's not defined in the route. + return mergeLodash( + { query: { _inspect: false } }, + pickBy(result.right, isNotEmpty) + ); } diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 2bd7e25e848c8..2b5fb0b516ab5 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -30,7 +30,8 @@ import { serviceDependenciesRoute, serviceMetadataDetailsRoute, serviceMetadataIconsRoute, - serviceInstancesRoute, + serviceInstancesPrimaryStatisticsRoute, + serviceInstancesComparisonStatisticsRoute, serviceProfilingStatisticsRoute, serviceProfilingTimelineRoute, } from './services'; @@ -134,7 +135,8 @@ const createApmApi = () => { .add(serviceDependenciesRoute) .add(serviceMetadataDetailsRoute) .add(serviceMetadataIconsRoute) - .add(serviceInstancesRoute) + .add(serviceInstancesPrimaryStatisticsRoute) + .add(serviceInstancesComparisonStatisticsRoute) .add(serviceErrorGroupsComparisonStatisticsRoute) .add(serviceProfilingTimelineRoute) .add(serviceProfilingStatisticsRoute) diff --git a/x-pack/plugins/apm/server/routes/create_route.ts b/x-pack/plugins/apm/server/routes/create_route.ts index 4d30e706cdd5c..d74aac0992eb4 100644 --- a/x-pack/plugins/apm/server/routes/create_route.ts +++ b/x-pack/plugins/apm/server/routes/create_route.ts @@ -6,20 +6,20 @@ */ import { CoreSetup } from 'src/core/server'; -import { Route, RouteParamsRT } from './typings'; +import { HandlerReturn, Route, RouteParamsRT } from './typings'; export function createRoute< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: Route ): Route; export function createRoute< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: (core: CoreSetup) => Route ): (core: CoreSetup) => Route; diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts index 448591f7e143f..4aa7d7e6d412f 100644 --- a/x-pack/plugins/apm/server/routes/environments.ts +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -30,10 +30,12 @@ export const environmentsRoute = createRoute({ setup ); - return getEnvironments({ + const environments = await getEnvironments({ setup, serviceName, searchAggregatedTransactions, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 710e614165aa5..f69d3fc9631d1 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -36,7 +36,7 @@ export const errorsRoute = createRoute({ const { serviceName } = params.path; const { environment, kuery, sortField, sortDirection } = params.query; - return getErrorGroups({ + const errorGroups = await getErrorGroups({ environment, kuery, serviceName, @@ -44,6 +44,8 @@ export const errorsRoute = createRoute({ sortDirection, setup, }); + + return { errorGroups }; }, }); diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index ed1354a219164..fd7d2120ab6f5 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -21,10 +21,13 @@ export const staticIndexPatternRoute = createRoute((core) => ({ getInternalSavedObjectsClient(core), ]); - await createStaticIndexPattern(setup, context, savedObjectsClient); + const didCreateIndexPattern = await createStaticIndexPattern( + setup, + context, + savedObjectsClient + ); - // send empty response regardless of outcome - return undefined; + return { created: didCreateIndexPattern }; }, })); @@ -41,6 +44,8 @@ export const apmIndexPatternTitleRoute = createRoute({ endpoint: 'GET /api/apm/index_pattern/title', options: { tags: ['access:apm'] }, handler: async ({ context }) => { - return getApmIndexPatternTitle(context); + return { + indexPatternTitle: getApmIndexPatternTitle(context), + }; }, }); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 1a1fa799639bc..b9c0a76b6fb90 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceCount } from '../lib/observability_overview/get_service_count'; import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; -import { hasData } from '../lib/observability_overview/has_data'; +import { getHasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; @@ -20,7 +20,8 @@ export const observabilityOverviewHasDataRoute = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return await hasData({ setup }); + const res = await getHasData({ setup }); + return { hasData: res }; }, }); diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index ecf56e2aec246..3156acb469a72 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -79,12 +79,14 @@ export const rumPageLoadDistributionRoute = createRoute({ query: { minPercentile, maxPercentile, urlQuery }, } = context.params; - return getPageLoadDistribution({ + const pageLoadDistribution = await getPageLoadDistribution({ setup, minPercentile, maxPercentile, urlQuery, }); + + return { pageLoadDistribution }; }, }); @@ -105,13 +107,15 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ query: { minPercentile, maxPercentile, breakdown, urlQuery }, } = context.params; - return getPageLoadDistBreakdown({ + const pageLoadDistBreakdown = await getPageLoadDistBreakdown({ setup, minPercentile: Number(minPercentile), maxPercentile: Number(maxPercentile), breakdown, urlQuery, }); + + return { pageLoadDistBreakdown }; }, }); @@ -145,7 +149,8 @@ export const rumServicesRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getRumServices({ setup }); + const rumServices = await getRumServices({ setup }); + return { rumServices }; }, }); @@ -322,12 +327,14 @@ function createLocalFiltersRoute< setup, }); - return getLocalUIFilters({ + const localUiFilters = await getLocalUIFilters({ projection, setup, uiFilters, localFilterNames: filterNames, }); + + return { localUiFilters }; }, }); } diff --git a/x-pack/plugins/apm/server/routes/service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes.ts index e65b0b679da5a..e9060688c63a6 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes.ts @@ -26,10 +26,7 @@ export const serviceNodesRoute = createRoute({ const { serviceName } = params.path; const { kuery } = params.query; - return getServiceNodes({ - kuery, - setup, - serviceName, - }); + const serviceNodes = await getServiceNodes({ kuery, setup, serviceName }); + return { serviceNodes }; }, }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index bac970416792b..b4d25ca8b2a06 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -8,7 +8,13 @@ import Boom from '@hapi/boom'; import * as t from 'io-ts'; import { uniq } from 'lodash'; +import { + LatencyAggregationType, + latencyAggregationTypeRt, +} from '../../common/latency_aggregation_types'; +import { ProfilingValueType } from '../../common/profiling'; import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; @@ -16,31 +22,26 @@ import { getServiceAnnotations } from '../lib/services/annotations'; import { getServices } from '../lib/services/get_services'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { getServiceErrorGroupPrimaryStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_primary_statistics'; import { getServiceErrorGroupPeriods } from '../lib/services/get_service_error_groups/get_service_error_group_comparison_statistics'; -import { getServiceInstances } from '../lib/services/get_service_instances'; +import { getServiceErrorGroupPrimaryStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_primary_statistics'; +import { getServiceInstancesComparisonStatisticsPeriods } from '../lib/services/get_service_instances/comparison_statistics'; +import { getServiceInstancesPrimaryStatistics } from '../lib/services/get_service_instances/primary_statistics'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; import { getThroughput } from '../lib/services/get_throughput'; -import { createRoute } from './create_route'; +import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; +import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; -import { jsonRt } from '../../common/runtime_types/json_rt'; +import { withApmSpan } from '../utils/with_apm_span'; +import { createRoute } from './create_route'; import { comparisonRangeRt, environmentRt, kueryRt, rangeRt, } from './default_api_types'; -import { withApmSpan } from '../utils/with_apm_span'; -import { getServiceProfilingStatistics } from '../lib/services/profiling/get_service_profiling_statistics'; -import { getServiceProfilingTimeline } from '../lib/services/profiling/get_service_profiling_timeline'; -import { ProfilingValueType } from '../../common/profiling'; -import { - latencyAggregationTypeRt, - LatencyAggregationType, -} from '../../common/latency_aggregation_types'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', @@ -55,15 +56,13 @@ export const servicesRoute = createRoute({ setup ); - const services = await getServices({ + return getServices({ environment, kuery, setup, searchAggregatedTransactions, logger: context.logger, }); - - return services; }, }); @@ -433,8 +432,9 @@ export const serviceThroughputRoute = createRoute({ }, }); -export const serviceInstancesRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances', +export const serviceInstancesPrimaryStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics', params: t.type({ path: t.type({ serviceName: t.string, @@ -443,11 +443,60 @@ export const serviceInstancesRoute = createRoute({ t.type({ latencyAggregationType: latencyAggregationTypeRt, transactionType: t.string, + }), + environmentRt, + kueryRt, + rangeRt, + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.path; + const { environment, kuery, transactionType } = context.params.query; + const latencyAggregationType = (context.params.query + .latencyAggregationType as unknown) as LatencyAggregationType; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + const { start, end } = setup; + + const serviceInstances = await getServiceInstancesPrimaryStatistics({ + environment, + kuery, + latencyAggregationType, + serviceName, + setup, + transactionType, + searchAggregatedTransactions, + start, + end, + }); + + return { serviceInstances }; + }, +}); + +export const serviceInstancesComparisonStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ + latencyAggregationType: latencyAggregationTypeRt, + transactionType: t.string, + serviceNodeIds: jsonRt.pipe(t.array(t.string)), numBuckets: toNumberRt, }), environmentRt, kueryRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, @@ -458,6 +507,9 @@ export const serviceInstancesRoute = createRoute({ environment, kuery, transactionType, + comparisonStart, + comparisonEnd, + serviceNodeIds, numBuckets, } = context.params.query; const latencyAggregationType = (context.params.query @@ -467,7 +519,7 @@ export const serviceInstancesRoute = createRoute({ setup ); - return getServiceInstances({ + return getServiceInstancesComparisonStatisticsPeriods({ environment, kuery, latencyAggregationType, @@ -476,6 +528,9 @@ export const serviceInstancesRoute = createRoute({ transactionType, searchAggregatedTransactions, numBuckets, + serviceNodeIds, + comparisonStart, + comparisonEnd, }); }, }); @@ -503,12 +558,14 @@ export const serviceDependenciesRoute = createRoute({ const { serviceName } = context.params.path; const { environment, numBuckets } = context.params.query; - return getServiceDependencies({ + const serviceDependencies = await getServiceDependencies({ serviceName, environment, setup, numBuckets, }); + + return { serviceDependencies }; }, }); @@ -531,12 +588,14 @@ export const serviceProfilingTimelineRoute = createRoute({ query: { environment, kuery }, } = context.params; - return getServiceProfilingTimeline({ + const profilingTimeline = await getServiceProfilingTimeline({ kuery, setup, serviceName, environment, }); + + return { profilingTimeline }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index e3ed398171d01..31e8d6cc1e9f0 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -31,7 +31,8 @@ export const agentConfigurationRoute = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return await listConfigurations({ setup }); + const configurations = await listConfigurations({ setup }); + return { configurations }; }, }); @@ -204,10 +205,12 @@ export const listAgentConfigurationServicesRoute = createRoute({ const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return await getServiceNames({ + const serviceNames = await getServiceNames({ setup, searchAggregatedTransactions, }); + + return { serviceNames }; }, }); @@ -225,11 +228,13 @@ export const listAgentConfigurationEnvironmentsRoute = createRoute({ setup ); - return await getEnvironments({ + const environments = await getEnvironments({ serviceName, setup, searchAggregatedTransactions, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index e5922d9ed3e94..de7f35c4081bc 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -71,6 +71,8 @@ export const createAnomalyDetectionJobsRoute = createRoute({ licensingPlugin: context.licensing, featureName: 'ml', }); + + return { jobCreated: true }; }, }); @@ -85,10 +87,12 @@ export const anomalyDetectionEnvironmentsRoute = createRoute({ setup ); - return await getAllEnvironments({ + const environments = await getAllEnvironments({ setup, searchAggregatedTransactions, includeMissing: true, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts index 0d47579f50aec..91057c97579e4 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts @@ -18,7 +18,8 @@ export const apmIndexSettingsRoute = createRoute({ endpoint: 'GET /api/apm/settings/apm-index-settings', options: { tags: ['access:apm'] }, handler: async ({ context }) => { - return await getApmIndexSettings({ context }); + const apmIndexSettings = await getApmIndexSettings({ context }); + return { apmIndexSettings }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index fc217bef772d0..a6ab553f09419 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -52,7 +52,8 @@ export const listCustomLinksRoute = createRoute({ const { query } = context.params; // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); - return await listCustomLinks({ setup, filters }); + const customLinks = await listCustomLinks({ setup, filters }); + return { customLinks }; }, }); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 4d3e07040f76b..1575041fb2f45 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -13,7 +13,7 @@ import { Logger, } from 'src/core/server'; import { Observable } from 'rxjs'; -import { RequiredKeys } from 'utility-types'; +import { RequiredKeys, DeepPartial } from 'utility-types'; import { ObservabilityPluginSetup } from '../../../observability/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; import { SecurityPluginSetup } from '../../../security/server'; @@ -21,6 +21,20 @@ import { MlPluginSetup } from '../../../ml/server'; import { FetchOptions } from '../../common/fetch_options'; import { APMConfig } from '..'; +export type HandlerReturn = Record; + +interface InspectQueryParam { + query: { _inspect: boolean }; +} + +export type InspectResponse = Array<{ + response: any; + duration: number; + requestType: string; + requestParams: Record; + esError: Error; +}>; + export interface RouteParams { path?: Record; query?: Record; @@ -36,15 +50,14 @@ export type RouteParamsRT = WithoutIncompatibleMethods>; export type RouteHandler< TParamsRT extends RouteParamsRT | undefined, - TReturn + TReturn extends HandlerReturn > = (kibanaContext: { context: APMRequestHandlerContext< - (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & { - query: { _debug: boolean }; - } + (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & + InspectQueryParam >; request: KibanaRequest; -}) => Promise; +}) => Promise; interface RouteOptions { tags: Array< @@ -58,7 +71,7 @@ interface RouteOptions { export interface Route< TEndpoint extends string, TRouteParamsRT extends RouteParamsRT | undefined, - TReturn + TReturn extends HandlerReturn > { endpoint: TEndpoint; options: RouteOptions; @@ -76,7 +89,7 @@ export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { export type APMRequestHandlerContext< TRouteParams = {} > = ApmPluginRequestHandlerContext & { - params: TRouteParams & { query: { _debug: boolean } }; + params: TRouteParams & InspectQueryParam; config: APMConfig; logger: Logger; plugins: { @@ -97,8 +110,8 @@ export interface ServerAPI { _S: TRouteState; add< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: | Route @@ -108,7 +121,7 @@ export interface ServerAPI { { [key in TEndpoint]: { params: TRouteParamsRT; - ret: TReturn; + ret: TReturn & { _inspect?: InspectResponse }; }; } >; @@ -132,6 +145,16 @@ type MaybeOptional }> = RequiredKeys< ? { params?: T['params'] } : { params: T['params'] }; +export type MaybeParams< + TRouteState, + TEndpoint extends keyof TRouteState & string +> = TRouteState[TEndpoint] extends { params: t.Any } + ? MaybeOptional<{ + params: t.OutputOf & + DeepPartial; + }> + : {}; + export type Client< TRouteState, TOptions extends { abortable: boolean } = { abortable: true } @@ -142,9 +165,7 @@ export type Client< > & { forceCache?: boolean; endpoint: TEndpoint; - } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.OutputOf }> - : {}) & + } & MaybeParams & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< TRouteState[TEndpoint] extends { ret: any } diff --git a/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts b/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts index 17b714f8b72b0..144d77df064c7 100644 --- a/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts +++ b/x-pack/plugins/canvas/server/collectors/custom_element_collector.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; import { get } from 'lodash'; import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; import { collectFns } from './collector_helpers'; @@ -125,12 +124,10 @@ const customElementCollector: TelemetryCollector = async function customElementC body: { query: { bool: { filter: { term: { type: CUSTOM_ELEMENT_TYPE } } } } }, }; - const { body: esResponse } = await esClient.search>( - customElementParams - ); + const { body: esResponse } = await esClient.search(customElementParams); if (get(esResponse, 'hits.hits.length') > 0) { - const customElements = esResponse.hits.hits.map((hit) => hit._source[CUSTOM_ELEMENT_TYPE]); + const customElements = esResponse.hits.hits.map((hit) => hit._source![CUSTOM_ELEMENT_TYPE]); return summarizeCustomElements(customElements); } diff --git a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts index 7cb1d17dff437..7342cb5d40357 100644 --- a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts +++ b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; import { sum as arraySum, min as arrayMin, max as arrayMax, get } from 'lodash'; import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; import { CANVAS_TYPE } from '../../common/lib/constants'; @@ -230,7 +229,6 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr variables: variableInfo, }; } -type ESResponse = SearchResponse; const workpadCollector: TelemetryCollector = async function (kibanaIndex, esClient) { const searchParams = { @@ -241,10 +239,10 @@ const workpadCollector: TelemetryCollector = async function (kibanaIndex, esClie body: { query: { bool: { filter: { term: { type: CANVAS_TYPE } } } } }, }; - const { body: esResponse } = await esClient.search(searchParams); + const { body: esResponse } = await esClient.search(searchParams); if (get(esResponse, 'hits.hits.length') > 0) { - const workpads = esResponse.hits.hits.map((hit) => hit._source[CANVAS_TYPE]); + const workpads = esResponse.hits.hits.map((hit) => hit._source![CANVAS_TYPE]); return summarizeWorkpads(workpads); } diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 1e7cff99a00bd..148b81c346b6e 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { DEFAULT_MAX_SIGNALS } from '../../security_solution/common/constants'; - export const APP_ID = 'cases'; /** @@ -53,5 +51,12 @@ export const SUPPORTED_CONNECTORS = [ * Alerts */ +// this value is from x-pack/plugins/security_solution/common/constants.ts +const DEFAULT_MAX_SIGNALS = 100; export const MAX_ALERTS_PER_SUB_CASE = 5000; export const MAX_GENERATED_ALERTS_PER_SUB_CASE = MAX_ALERTS_PER_SUB_CASE / DEFAULT_MAX_SIGNALS; + +/** + * This flag governs enabling the case as a connector feature. It is disabled by default as the feature is not complete. + */ +export const ENABLE_CASE_CONNECTOR = false; diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 59f9688836341..650b9aa81c990 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -35,6 +35,7 @@ import { CaseUserActionServiceSetup, } from '../../services'; import { createCaseError } from '../../common/error'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; interface CreateCaseArgs { caseConfigureService: CaseConfigureServiceSetup; @@ -60,9 +61,19 @@ export const create = async ({ }: CreateCaseArgs): Promise => { // default to an individual case if the type is not defined. const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; + + if (!ENABLE_CASE_CONNECTOR && type === CaseType.collection) { + throw Boom.badRequest( + 'Case type cannot be collection when the case connector feature is disabled' + ); + } + const query = pipe( // decode with the defaulted type field - excess(CasesClientPostRequestRt).decode({ type, ...nonTypeCaseFields }), + excess(CasesClientPostRequestRt).decode({ + type, + ...nonTypeCaseFields, + }), fold(throwErrors(Boom.badRequest), identity) ); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index fa556986ee8d3..50725879278e4 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -5,12 +5,13 @@ * 2.0. */ -import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { SavedObjectsClientContract, Logger, SavedObject } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; -import { CaseResponseRt, CaseResponse } from '../../../common/api'; +import { CaseResponseRt, CaseResponse, ESCaseAttributes } from '../../../common/api'; import { CaseServiceSetup } from '../../services'; import { countAlertsForID } from '../../common'; import { createCaseError } from '../../common/error'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; interface GetParams { savedObjectsClient: SavedObjectsClientContract; @@ -33,15 +34,26 @@ export const get = async ({ includeSubCaseComments = false, }: GetParams): Promise => { try { - const [theCase, subCasesForCaseId] = await Promise.all([ - caseService.getCase({ + let theCase: SavedObject; + let subCaseIds: string[] = []; + + if (ENABLE_CASE_CONNECTOR) { + const [caseInfo, subCasesForCaseId] = await Promise.all([ + caseService.getCase({ + client: savedObjectsClient, + id, + }), + caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), + ]); + + theCase = caseInfo; + subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); + } else { + theCase = await caseService.getCase({ client: savedObjectsClient, id, - }), - caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), - ]); - - const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); + }); + } if (!includeComments) { return CaseResponseRt.encode( @@ -58,7 +70,7 @@ export const get = async ({ sortField: 'created_at', sortOrder: 'asc', }, - includeSubCaseComments, + includeSubCaseComments: ENABLE_CASE_CONNECTOR && includeSubCaseComments, }); return CaseResponseRt.encode( diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 3217178768f89..216ef109534fb 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -40,6 +40,7 @@ import { } from '../../services'; import { CasesClientHandler } from '../client'; import { createCaseError } from '../../common/error'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; /** * Returns true if the case should be closed based on the configuration settings and whether the case @@ -92,7 +93,11 @@ export const push = async ({ try { [theCase, connector, userActions] = await Promise.all([ - casesClient.get({ id: caseId, includeComments: true, includeSubCaseComments: true }), + casesClient.get({ + id: caseId, + includeComments: true, + includeSubCaseComments: ENABLE_CASE_CONNECTOR, + }), actionsClient.get({ id: connectorId }), casesClient.getUserActions({ caseId }), ]); @@ -183,7 +188,7 @@ export const push = async ({ page: 1, perPage: theCase?.totalComment ?? 0, }, - includeSubCaseComments: true, + includeSubCaseComments: ENABLE_CASE_CONNECTOR, }), ]); } catch (e) { diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index ff3c0a62407a1..b39bfe6ec4eb7 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -55,6 +55,7 @@ import { CasesClientHandler } from '..'; import { createAlertUpdateRequest } from '../../common'; import { UpdateAlertRequest } from '../types'; import { createCaseError } from '../../common/error'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -97,6 +98,22 @@ function throwIfUpdateTypeCollectionToIndividual( } } +/** + * Throws an error if any of the requests attempt to update the type of a case. + */ +function throwIfUpdateType(requests: ESCasePatchRequest[]) { + const requestsUpdatingType = requests.filter((req) => req.type !== undefined); + + if (requestsUpdatingType.length > 0) { + const ids = requestsUpdatingType.map((req) => req.id); + throw Boom.badRequest( + `Updating the type of a case when sub cases are disabled is not allowed ids: [${ids.join( + ', ' + )}]` + ); + } +} + /** * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection * when alerts are attached to the case. @@ -396,6 +413,10 @@ export const update = async ({ return acc; }, new Map>()); + if (!ENABLE_CASE_CONNECTOR) { + throwIfUpdateType(updateFilterCases); + } + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); await throwIfInvalidUpdateOfTypeWithAlerts({ diff --git a/x-pack/plugins/cases/server/client/comments/add.ts b/x-pack/plugins/cases/server/client/comments/add.ts index 45746613dc1d4..5a119432b3ccb 100644 --- a/x-pack/plugins/cases/server/client/comments/add.ts +++ b/x-pack/plugins/cases/server/client/comments/add.ts @@ -36,7 +36,10 @@ import { CommentableCase, createAlertUpdateRequest } from '../../common'; import { CasesClientHandler } from '..'; import { createCaseError } from '../../common/error'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; -import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants'; +import { + ENABLE_CASE_CONNECTOR, + MAX_GENERATED_ALERTS_PER_SUB_CASE, +} from '../../../common/constants'; async function getSubCase({ caseService, @@ -224,10 +227,14 @@ async function getCombinedCase({ client, id, }), - service.getSubCase({ - client, - id, - }), + ...(ENABLE_CASE_CONNECTOR + ? [ + service.getSubCase({ + client, + id, + }), + ] + : [Promise.reject('case connector feature is disabled')]), ]); if (subCasePromise.status === 'fulfilled') { @@ -287,6 +294,12 @@ export const addComment = async ({ ); if (isCommentRequestTypeGenAlert(comment)) { + if (!ENABLE_CASE_CONNECTOR) { + throw Boom.badRequest( + 'Attempting to add a generated alert when case connector feature is disabled' + ); + } + return addGeneratedAlerts({ caseId, comment, diff --git a/x-pack/plugins/cases/server/connectors/case/index.test.ts b/x-pack/plugins/cases/server/connectors/case/index.test.ts index fa2b10a0ccbdb..8a025ed0f79b7 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -886,7 +886,34 @@ describe('case connector', () => { }); }); - describe('execute', () => { + it('should throw an error when executing the connector', async () => { + expect.assertions(2); + const actionId = 'some-id'; + const params: CaseExecutorParams = { + // @ts-expect-error + subAction: 'not-supported', + // @ts-expect-error + subActionParams: {}, + }; + + const executorOptions: CaseActionTypeExecutorOptions = { + actionId, + config: {}, + params, + secrets: {}, + services, + }; + + try { + await caseActionType.executor(executorOptions); + } catch (e) { + expect(e).not.toBeNull(); + expect(e.message).toBe('[Action][Case] connector not supported'); + } + }); + + // ENABLE_CASE_CONNECTOR: enable these tests after the case connector feature is completed + describe.skip('execute', () => { it('allows only supported sub-actions', async () => { expect.assertions(2); const actionId = 'some-id'; diff --git a/x-pack/plugins/cases/server/connectors/case/index.ts b/x-pack/plugins/cases/server/connectors/case/index.ts index da993faf0ef5c..c5eb609e260ae 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.ts @@ -27,6 +27,7 @@ import * as i18n from './translations'; import { GetActionTypeParams, isCommentGeneratedAlert, separator } from '..'; import { nullUser } from '../../common'; import { createCaseError } from '../../common/error'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -70,6 +71,12 @@ async function executor( }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { + if (!ENABLE_CASE_CONNECTOR) { + const msg = '[Action][Case] connector not supported'; + logger.error(msg); + throw new Error(msg); + } + const { actionId, params, services } = execOptions; const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 0c661cc18c21b..8b53fd77d98a5 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -10,7 +10,7 @@ import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; -import { APP_ID } from '../common/constants'; +import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common/constants'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; @@ -70,7 +70,6 @@ export class CasePlugin { core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); - core.savedObjects.registerType(subCaseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -111,15 +110,18 @@ export class CasePlugin { router, }); - registerConnectors({ - actionsRegisterType: plugins.actions.registerType, - logger: this.log, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - alertsService: this.alertsService, - }); + if (ENABLE_CASE_CONNECTOR) { + core.savedObjects.registerType(subCaseSavedObjectType); + registerConnectors({ + actionsRegisterType: plugins.actions.registerType, + logger: this.log, + caseService: this.caseService, + caseConfigureService: this.caseConfigureService, + connectorMappingsService: this.connectorMappingsService, + userActionService: this.userActionService, + alertsService: this.alertsService, + }); + } } public start(core: CoreStart) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts index fd250b74fff1e..7f6cfb224fada 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,12 +5,12 @@ * 2.0. */ +import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; - import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ @@ -35,18 +35,23 @@ export function initDeleteAllCommentsApi({ }, async (context, request, response) => { try { + if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } + const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const id = request.query?.subCaseId ?? request.params.case_id; + const subCaseId = request.query?.subCaseId; + const id = subCaseId ?? request.params.case_id; const comments = await caseService.getCommentsByAssociation({ client, id, - associationType: request.query?.subCaseId - ? AssociationType.subCase - : AssociationType.case, + associationType: subCaseId ? AssociationType.subCase : AssociationType.case, }); await Promise.all( @@ -66,7 +71,7 @@ export function initDeleteAllCommentsApi({ actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.subCaseId, + subCaseId, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts index f1c5fdc2b7cc8..f8771f92c417f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts @@ -12,7 +12,7 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_obje import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; export function initDeleteCommentApi({ caseService, @@ -37,6 +37,12 @@ export function initDeleteCommentApi({ }, async (context, request, response) => { try { + if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } + const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts index 57ddd84e8742c..9468b2b01fe37 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts @@ -22,7 +22,7 @@ import { } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ @@ -49,6 +49,12 @@ export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDe fold(throwErrors(Boom.badRequest), identity) ); + if (!ENABLE_CASE_CONNECTOR && query.subCaseId !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } + const id = query.subCaseId ?? request.params.case_id; const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case; const args = query diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts index 770efe0109744..2699f7a0307f7 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts @@ -5,13 +5,14 @@ * 2.0. */ +import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { SavedObjectsFindResponse } from 'kibana/server'; import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { @@ -35,6 +36,16 @@ export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps const client = context.core.savedObjects.client; let comments: SavedObjectsFindResponse; + if ( + !ENABLE_CASE_CONNECTOR && + (request.query?.subCaseId !== undefined || + request.query?.includeSubCaseComments !== undefined) + ) { + throw Boom.badRequest( + 'The `subCaseId` and `includeSubCaseComments` are not supported when the case connector feature is disabled' + ); + } + if (request.query?.subCaseId) { comments = await caseService.getAllSubCaseComments({ client, diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts index f5db2dc004a1d..519692d2d78a1 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts @@ -19,7 +19,7 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_obje import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; import { CaseServiceSetup } from '../../../../services'; interface CombinedCaseParams { @@ -82,6 +82,12 @@ export function initPatchCommentApi({ caseService, router, userActionService, lo }, async (context, request, response) => { try { + if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } + const client = context.core.savedObjects.client; const query = pipe( CommentPatchRequestRt.decode(request.body), diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts index 110a16a610014..8658f9ba0aac5 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts @@ -5,10 +5,11 @@ * 2.0. */ +import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { escapeHatch, wrapError } from '../../utils'; import { RouteDeps } from '../../types'; -import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; import { CommentRequest } from '../../../../../common/api'; export function initPostCommentApi({ router, logger }: RouteDeps) { @@ -28,15 +29,21 @@ export function initPostCommentApi({ router, logger }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.cases) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } - const casesClient = context.cases.getCasesClient(); - const caseId = request.query?.subCaseId ?? request.params.case_id; - const comment = request.body as CommentRequest; + if (!context.cases) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const casesClient = context.cases.getCasesClient(); + const caseId = request.query?.subCaseId ?? request.params.case_id; + const comment = request.body as CommentRequest; - try { return response.ok({ body: await casesClient.addComment({ caseId, comment }), }); diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts index 5f2a6c67220c3..d91859d4e8cbb 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts @@ -11,7 +11,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASES_URL } from '../../../../common/constants'; +import { CASES_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; async function deleteSubCases({ @@ -91,7 +91,10 @@ export function initDeleteCasesApi({ caseService, router, userActionService, log ); } - await deleteSubCases({ caseService, client, caseIds: request.query.ids }); + if (ENABLE_CASE_CONNECTOR) { + await deleteSubCases({ caseService, client, caseIds: request.query.ids }); + } + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); @@ -104,7 +107,14 @@ export function initDeleteCasesApi({ caseService, router, userActionService, log actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - fields: ['comment', 'description', 'status', 'tags', 'title', 'sub_case'], + fields: [ + 'comment', + 'description', + 'status', + 'tags', + 'title', + ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), + ], }) ), }); diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts index bc6907f52b9eb..10406d0edcd46 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts @@ -66,11 +66,13 @@ export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { return response.ok({ body: CasesFindResponseRt.encode( transformCases({ - ...cases, + casesMap: cases.casesMap, + page: cases.page, + perPage: cases.perPage, + total: cases.total, countOpenCases: openCases, countInProgressCases: inProgressCases, countClosedCases: closedCases, - total: cases.casesMap.size, }) ), }); diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index f464f7e47fe7a..e8e35d875f42f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -7,9 +7,10 @@ import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( @@ -27,6 +28,11 @@ export function initGetCaseApi({ router, logger }: RouteDeps) { }, async (context, request, response) => { try { + if (!ENABLE_CASE_CONNECTOR && request.query.includeSubCaseComments !== undefined) { + throw Boom.badRequest( + 'The `subCaseId` is not supported when the case connector feature is disabled' + ); + } const casesClient = context.cases.getCasesClient(); const id = request.params.case_id; diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts index 8659ab02d6d53..4e6c07d05bc17 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts @@ -13,12 +13,13 @@ import { CaseConnector, ESCaseConnector, ESCasesConfigureAttributes, + ConnectorTypeFields, ConnectorTypes, CaseStatuses, CaseType, SavedObjectFindOptions, } from '../../../../common/api'; -import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +import { ESConnectorFields } from '../../../../common/api/connectors'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { sortToSnake } from '../utils'; import { combineFilters } from '../../../common'; diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts index c24dde1944f83..e7f9f8b4f2d73 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -78,12 +78,13 @@ export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) return response.ok({ body: SubCasesFindResponseRt.encode( transformSubCases({ - ...subCases, + page: subCases.page, + perPage: subCases.perPage, + total: subCases.total, + subCasesMap: subCases.subCasesMap, open, inProgress, closed, - // there should only be one entry in the map for the requested case ID - total: subCases.subCasesMap.get(request.params.case_id)?.length ?? 0, }) ), }); diff --git a/x-pack/plugins/cases/server/routes/api/index.ts b/x-pack/plugins/cases/server/routes/api/index.ts index 12d1da36077c7..c5b7aa85dc33e 100644 --- a/x-pack/plugins/cases/server/routes/api/index.ts +++ b/x-pack/plugins/cases/server/routes/api/index.ts @@ -37,6 +37,7 @@ import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; /** * Default page number when interacting with the saved objects API. @@ -56,12 +57,16 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseApi(deps); initGetAllCaseUserActionsApi(deps); - initGetAllSubCaseUserActionsApi(deps); - // Sub cases - initGetSubCaseApi(deps); - initPatchSubCasesApi(deps); - initFindSubCasesApi(deps); - initDeleteSubCasesApi(deps); + + if (ENABLE_CASE_CONNECTOR) { + // Sub cases + initGetAllSubCaseUserActionsApi(deps); + initGetSubCaseApi(deps); + initPatchSubCasesApi(deps); + initFindSubCasesApi(deps); + initDeleteSubCasesApi(deps); + } + // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index 6ce4db61ab956..db8e841f45ee4 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -84,8 +84,9 @@ export class AlertService { return; } - const results = await scopedClusterClient.mget({ body: { docs } }); + const results = await scopedClusterClient.mget({ body: { docs } }); + // @ts-expect-error @elastic/elasticsearch _source is optional return results.body; } catch (error) { throw createCaseError({ diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 11ceb48d11e9f..48a1a1ed68432 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -34,6 +34,7 @@ import { caseTypeField, CasesFindRequest, } from '../../common/api'; +import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; import { defaultPage, defaultPerPage } from '../routes/api'; import { @@ -170,6 +171,7 @@ interface SubCasesMapWithPageInfo { subCasesMap: Map; page: number; perPage: number; + total: number; } interface CaseCommentStats { @@ -193,6 +195,7 @@ interface CasesMapWithPageInfo { casesMap: Map; page: number; perPage: number; + total: number; } type FindCaseOptions = CasesFindRequest & SavedObjectFindOptions; @@ -282,13 +285,15 @@ export class CaseService implements CaseServiceSetup { options: caseOptions, }); - const subCasesResp = await this.findSubCasesGroupByCase({ - client, - options: subCaseOptions, - ids: cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) - .map((caseInfo) => caseInfo.id), - }); + const subCasesResp = ENABLE_CASE_CONNECTOR + ? await this.findSubCasesGroupByCase({ + client, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }) + : { subCasesMap: new Map(), page: 0, perPage: 0 }; const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); @@ -345,6 +350,7 @@ export class CaseService implements CaseServiceSetup { casesMap: casesWithComments, page: cases.page, perPage: cases.per_page, + total: cases.total, }; } @@ -407,7 +413,7 @@ export class CaseService implements CaseServiceSetup { let subCasesTotal = 0; - if (subCaseOptions) { + if (ENABLE_CASE_CONNECTOR && subCaseOptions) { subCasesTotal = await this.findSubCaseStatusStats({ client, options: subCaseOptions, @@ -526,6 +532,7 @@ export class CaseService implements CaseServiceSetup { subCasesMap: new Map(), page: 0, perPage: 0, + total: 0, }; if (!options) { @@ -582,7 +589,7 @@ export class CaseService implements CaseServiceSetup { return accMap; }, new Map()); - return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; + return { subCasesMap, page: subCases.page, perPage: subCases.per_page, total: subCases.total }; } /** diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 056135b34cf9f..439cae4f414f7 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,8 +17,6 @@ import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; -import { setAutocompleteService } from './services'; -import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; import { EnhancedSearchInterceptor } from './search/search_interceptor'; import { registerSearchSessionsMgmt } from './search/sessions_mgmt'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; @@ -52,11 +50,6 @@ export class DataEnhancedPlugin core: CoreSetup, { bfetch, data, management }: DataEnhancedSetupDependencies ) { - data.autocomplete.addQuerySuggestionProvider( - KUERY_LANGUAGE_NAME, - setupKqlQuerySuggestionProvider(core) - ); - this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ bfetch, toasts: core.notifications.toasts, @@ -83,8 +76,6 @@ export class DataEnhancedPlugin } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { - setAutocompleteService(plugins.data.autocomplete); - if (this.config.search.sessions.enabled) { core.chrome.setBreadcrumbsAppendExtension({ content: toMountPoint( diff --git a/x-pack/plugins/data_enhanced/public/services.ts b/x-pack/plugins/data_enhanced/public/services.ts deleted file mode 100644 index 5eef7e391bdd4..0000000000000 --- a/x-pack/plugins/data_enhanced/public/services.ts +++ /dev/null @@ -1,13 +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 { createGetterSetter } from '../../../../src/plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; - -export const [getAutocompleteService, setAutocompleteService] = createGetterSetter< - DataPublicPluginStart['autocomplete'] ->('Autocomplete'); diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts index 428de148fdd4f..f0a1ebf52f6a1 100644 --- a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -36,7 +36,8 @@ export function fetchProvider(config$: Observable, logger: L }, }); - const { buckets } = esResponse.aggregations.persisted; + // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregations + const buckets: SessionPersistedTermsBucket[] = esResponse.aggregations!.persisted.buckets; if (!buckets.length) { return { transientCount: 0, persistedCount: 0, totalCount: 0 }; } diff --git a/x-pack/plugins/data_enhanced/server/routes/session.ts b/x-pack/plugins/data_enhanced/server/routes/session.ts index 185032bd25bb6..0b786f44454a9 100644 --- a/x-pack/plugins/data_enhanced/server/routes/session.ts +++ b/x-pack/plugins/data_enhanced/server/routes/session.ts @@ -98,7 +98,7 @@ export function registerSessionRoutes(router: DataEnhancedPluginRouter, logger: page: schema.maybe(schema.number()), perPage: schema.maybe(schema.number()), sortField: schema.maybe(schema.string()), - sortOrder: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), filter: schema.maybe(schema.string()), searchFields: schema.maybe(schema.arrayOf(schema.string())), search: schema.maybe(schema.string()), diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts index 526d01dade7a7..9c1bedc4d5f1c 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { ApiResponse } from '@elastic/elasticsearch'; import { tap } from 'rxjs/operators'; import type { IScopedClusterClient, Logger } from 'kibana/server'; import type { ISearchStrategy } from '../../../../../src/plugins/data/server'; @@ -51,13 +51,10 @@ export const eqlSearchStrategyProvider = ( ...request.params, }; const promise = id - ? client.get({ ...params, id }, request.options) - : client.search( - params as EqlSearchStrategyRequest['params'], - request.options - ); + ? client.get({ ...params, id }, request.options) + : client.search(params as EqlSearchStrategyRequest['params'], request.options); const response = await shimAbortSignal(promise, options.abortSignal); - return toEqlKibanaSearchResponse(response); + return toEqlKibanaSearchResponse(response as ApiResponse); }; const cancel = async () => { diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts index d529e981aaea1..2ae79f4e144e0 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts @@ -347,7 +347,7 @@ describe('ES search strategy', () => { expect(mockGetCaller).toBeCalled(); const request = mockGetCaller.mock.calls[0][0]; - expect(request).toEqual({ id, keep_alive: keepAlive }); + expect(request).toEqual({ id, body: { keep_alive: keepAlive } }); }); it('throws normalized error on ElasticsearchClientError', async () => { diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index fc1cc63146358..aec2e7bd533ec 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -34,7 +34,6 @@ import { getIgnoreThrottled, } from './request_utils'; import { toAsyncKibanaSearchResponse } from './response_utils'; -import { AsyncSearchResponse } from './types'; import { ConfigSchema } from '../../config'; import { getKbnServerError, KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; @@ -66,12 +65,14 @@ export const enhancedEsSearchStrategyProvider = ( ...(await getDefaultAsyncSubmitParams(uiSettingsClient, config, options)), ...request.params, }; - const promise = id - ? client.get({ ...params, id }) - : client.submit(params); + const promise = id ? client.get({ ...params, id }) : client.submit(params); const { body } = await shimAbortSignal(promise, options.abortSignal); const response = shimHitsTotal(body.response, options); - return toAsyncKibanaSearchResponse({ ...body, response }); + + return toAsyncKibanaSearchResponse( + // @ts-expect-error @elastic/elasticsearch start_time_in_millis expected to be number + { ...body, response } + ); }; const cancel = async () => { @@ -167,7 +168,7 @@ export const enhancedEsSearchStrategyProvider = ( extend: async (id, keepAlive, options, { esClient }) => { logger.debug(`extend ${id} by ${keepAlive}`); try { - await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + await esClient.asCurrentUser.asyncSearch.get({ id, body: { keep_alive: keepAlive } }); } catch (e) { throw getKbnServerError(e); } diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index 9fe4c293a8741..dffccbee9db92 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -18,6 +18,7 @@ export async function getSearchStatus( ): Promise> { // TODO: Handle strategies other than the default one try { + // @ts-expect-error @elastic/elasticsearch status method is not defined const apiResponse: ApiResponse = await client.asyncSearch.status({ id: asyncId, }); diff --git a/x-pack/plugins/data_enhanced/server/search/types.ts b/x-pack/plugins/data_enhanced/server/search/types.ts index df3ca86466aa9..e2a4e2ce74f15 100644 --- a/x-pack/plugins/data_enhanced/server/search/types.ts +++ b/x-pack/plugins/data_enhanced/server/search/types.ts @@ -5,11 +5,12 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import { SearchResponse, ShardsResponse } from 'elasticsearch'; export interface AsyncSearchResponse { id?: string; - response: SearchResponse; + response: estypes.SearchResponse; start_time_in_millis: number; expiration_time_in_millis: number; is_partial: boolean; diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 216c115545a45..047b9b06516ba 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -13,8 +13,6 @@ "server/**/*", "config.ts", "../../../typings/**/*", - // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 - "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json", "common/search/test_data/*.json" ], "references": [ diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 76f5cb49c7f07..d18e7e427eeca 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1820,3 +1820,30 @@ describe('#closePointInTime', () => { expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); }); }); + +describe('#createPointInTimeFinder', () => { + it('redirects request to underlying base client with default dependencies', () => { + const options = { type: ['a', 'b'], search: 'query' }; + wrapper.createPointInTimeFinder(options); + + expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, { + client: wrapper, + }); + }); + + it('redirects request to underlying base client with custom dependencies', () => { + const options = { type: ['a', 'b'], search: 'query' }; + const dependencies = { + client: { + find: jest.fn(), + openPointInTimeForType: jest.fn(), + closePointInTime: jest.fn(), + }, + }; + wrapper.createPointInTimeFinder(options, dependencies); + + expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, dependencies); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 6b06f7e4e68e9..88a89af6be3d0 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -19,6 +19,8 @@ import type { SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, SavedObjectsCreateOptions, + SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, SavedObjectsFindResponse, @@ -263,6 +265,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.closePointInTime(id, options); } + public createPointInTimeFinder( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ) { + return this.options.baseClient.createPointInTimeFinder(findOptions, { + client: this, + // Include dependencies last so that subsequent SO client wrappers have their settings applied. + ...dependencies, + }); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx index a0b0e4402c1e4..624cc57e1eb22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; -import { runActionColumnTests } from './shared_columns_tests'; +import { runActionColumnTests } from './test_helpers/shared_columns_tests'; import { AnalyticsTable } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx index 0670624492db5..6021363183098 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; -import { runActionColumnTests } from './shared_columns_tests'; +import { runActionColumnTests } from './test_helpers/shared_columns_tests'; import { RecentQueriesTable } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx similarity index 96% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx index cb78a6585e43c..95af7b52487d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx @@ -9,8 +9,8 @@ import { mockHttpValues, mockKibanaValues, mockFlashMessageHelpers, -} from '../../../../../__mocks__'; -import '../../../../__mocks__/engine_logic.mock'; +} from '../../../../../../__mocks__'; +import '../../../../../__mocks__/engine_logic.mock'; import { ReactWrapper } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx new file mode 100644 index 0000000000000..da57fd466ffe1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiPageHeader } from '@elastic/eui'; + +import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention'; + +import { ApiLogs } from './'; + +describe('ApiLogs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); + // TODO: Check for ApiLogsTable + NewApiEventsPrompt when those get added + + expect(wrapper.find(LogRetentionCallout).prop('type')).toEqual('api'); + expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx new file mode 100644 index 0000000000000..7e3fadb44fc7a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPageHeader, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; + +import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; + +import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; + +interface Props { + engineBreadcrumb: BreadcrumbTrail; +} +export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { + return ( + <> + + + + + + + + + +

{RECENT_API_EVENTS}

+
+
+ + + + {/* TODO: NewApiEventsPrompt */} +
+ + {/* TODO: ApiLogsTable */} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts index b67dee28f80d7..104ae03b89220 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts @@ -6,3 +6,4 @@ */ export { API_LOGS_TITLE } from './constants'; +export { ApiLogs } from './api_logs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx index fc411c3dff866..72cbe5bdd898c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -11,7 +11,6 @@ import { useActions, useValues } from 'kea'; import { EuiPageHeader, - EuiPageHeaderSection, EuiTitle, EuiPageContentBody, EuiPanel, @@ -55,13 +54,7 @@ export const Credentials: React.FC = () => { return ( <> - - - -

{CREDENTIALS_TITLE}

-
-
-
+ {shouldShowCredentialsForm && } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index a178228f4996b..bf84b03e7603e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -1025,7 +1025,9 @@ describe('CredentialsLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('fetchCredentials', () => { const meta = { page: { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index d06144023e170..e680579f7b0b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { shallow, ReactWrapper } from 'enzyme'; -import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiPageHeader, EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; import { Loading } from '../../../../shared/loading'; @@ -64,7 +64,7 @@ describe('Curations', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('h1').text()).toEqual('Curated results'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Curated results'); expect(wrapper.find(CurationsTable)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index fd0a36dfebec7..42b030328ce9a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -11,9 +11,7 @@ import { useValues, useActions } from 'kea'; import { EuiPageHeader, - EuiPageHeaderSection, EuiPageContent, - EuiTitle, EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt, @@ -47,18 +45,14 @@ export const Curations: React.FC = () => { return ( <> - - - -

{CURATIONS_OVERVIEW_TITLE}

-
-
- + {CREATE_NEW_CURATION_TITLE} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 37d3d1577767f..2c6cadf9a8ece 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -294,7 +294,9 @@ describe('DocumentCreationLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('onSubmitFile', () => { describe('with a valid file', () => { beforeAll(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index ba060b7497270..a33161918c7f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -14,7 +14,7 @@ import { useParams } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { EuiPageContent, EuiBasicTable } from '@elastic/eui'; +import { EuiPageHeader, EuiPageContent, EuiBasicTable } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { ResultFieldValue } from '../result'; @@ -102,7 +102,8 @@ describe('DocumentDetail', () => { it('will delete the document when the delete button is pressed', () => { const wrapper = shallow(); - const button = wrapper.find('[data-test-subj="DeleteDocumentButton"]'); + const header = wrapper.find(EuiPageHeader).dive().children().dive(); + const button = header.find('[data-test-subj="DeleteDocumentButton"]'); button.simulate('click'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index 8f80978c29002..0ad000d289d2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -13,8 +13,6 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, EuiPageContentBody, EuiPageContent, EuiBasicTable, @@ -79,13 +77,9 @@ export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { return ( <> - - - -

{DOCUMENT_DETAIL_TITLE(documentTitle)}

-
-
- + = ({ engineBreadcrumb }) => { {i18n.translate('xpack.enterpriseSearch.appSearch.documentDetail.deleteButton', { defaultMessage: 'Delete', })} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index d2683fac649a0..add5e9414be13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -54,7 +54,9 @@ describe('DocumentDetailLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('getDocumentDetails', () => { it('will call an API endpoint and then store the result', async () => { const fields = [{ name: 'name', value: 'python', type: 'string' }]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx index ace76ae55c046..ed4773e257a2b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx @@ -9,7 +9,9 @@ import { setMockValues } from '../../../__mocks__/kea.mock'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiPageHeader } from '@elastic/eui'; import { DocumentCreationButton } from './document_creation_button'; import { SearchExperience } from './search_experience'; @@ -32,46 +34,61 @@ describe('Documents', () => { expect(wrapper.find(SearchExperience).exists()).toBe(true); }); - it('renders a DocumentCreationButton if the user can manage engine documents', () => { - setMockValues({ - ...values, - myRole: { canManageEngineDocuments: true }, + describe('DocumentCreationButton', () => { + const getHeader = (wrapper: ShallowWrapper) => + wrapper.find(EuiPageHeader).dive().children().dive(); + + it('renders a DocumentCreationButton if the user can manage engine documents', () => { + setMockValues({ + ...values, + myRole: { canManageEngineDocuments: true }, + }); + + const wrapper = shallow(); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); - const wrapper = shallow(); - expect(wrapper.find(DocumentCreationButton).exists()).toBe(true); - }); + it('does not render a DocumentCreationButton if the user cannot manage engine documents', () => { + setMockValues({ + ...values, + myRole: { canManageEngineDocuments: false }, + }); - describe('Meta Engines', () => { - it('renders a Meta Engines message if this is a meta engine', () => { + const wrapper = shallow(); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + }); + + it('does not render a DocumentCreationButton for meta engines even if the user can manage engine documents', () => { setMockValues({ ...values, + myRole: { canManageEngineDocuments: true }, isMetaEngine: true, }); const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); + }); - it('does not render a Meta Engines message if this is not a meta engine', () => { + describe('Meta Engines', () => { + it('renders a Meta Engines message if this is a meta engine', () => { setMockValues({ ...values, - isMetaEngine: false, + isMetaEngine: true, }); const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); }); - it('does not render a DocumentCreationButton even if the user can manage engine documents', () => { + it('does not render a Meta Engines message if this is not a meta engine', () => { setMockValues({ ...values, - myRole: { canManageEngineDocuments: true }, - isMetaEngine: true, + isMetaEngine: false, }); const wrapper = shallow(); - expect(wrapper.find(DocumentCreationButton).exists()).toBe(false); + expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index 8c3ae7fd24f6d..84fcab53e9604 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiPageHeader, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -33,18 +33,14 @@ export const Documents: React.FC = ({ engineBreadcrumb }) => { return ( <> - - - -

{DOCUMENTS_TITLE}

-
-
- {myRole.canManageEngineDocuments && !isMetaEngine && ( - - - - )} -
+ ] + : undefined + } + /> {isMetaEngine && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index bf2fba6344e7a..b9ec83db99f70 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -172,7 +172,9 @@ describe('EngineLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('initializeEngine', () => { it('fetches and sets engine data', async () => { mount({ engineName: 'some-engine' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index f3a67c0d10389..2d7e3438d4c02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -246,8 +246,7 @@ export const EngineNav: React.FC = () => { )} {canViewEngineApiLogs && ( {API_LOGS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 7355ee148814c..27ef42e72764c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -17,6 +17,7 @@ import { shallow } from 'enzyme'; import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; +import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; @@ -119,4 +120,11 @@ describe('EngineRouter', () => { expect(wrapper.find(ResultSettings)).toHaveLength(1); }); + + it('renders an API logs view', () => { + setMockValues({ ...values, myRole: { canViewEngineApiLogs: true } }); + const wrapper = shallow(); + + expect(wrapper.find(ApiLogs)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 8eb50626fcb2b..88a24755070ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -31,9 +31,10 @@ import { ENGINE_CURATIONS_PATH, ENGINE_RESULT_SETTINGS_PATH, // ENGINE_SEARCH_UI_PATH, - // ENGINE_API_LOGS_PATH, + ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; +import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { OVERVIEW_TITLE } from '../engine_overview'; @@ -58,7 +59,7 @@ export const EngineRouter: React.FC = () => { canManageEngineCurations, canManageEngineResultSettings, // canManageEngineSearchUi, - // canViewEngineApiLogs, + canViewEngineApiLogs, }, } = useValues(AppLogic); @@ -115,6 +116,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineApiLogs && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx index 497c00d1f9144..bab31d0fccc40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -18,9 +18,7 @@ import { EuiSelect, EuiPageBody, EuiPageHeader, - EuiPageHeaderSection, EuiSpacer, - EuiText, EuiTitle, EuiButton, EuiPanel, @@ -49,13 +47,7 @@ export const EngineCreation: React.FC = () => { return (
- - - -

{ENGINE_CREATION_TITLE}

-
-
-
+ @@ -68,7 +60,7 @@ export const EngineCreation: React.FC = () => { }} > - {ENGINE_CREATION_FORM_TITLE} +

{ENGINE_CREATION_FORM_TITLE}

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index 9066283229a04..ea47dc8956ddd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiPageHeader, EuiButton } from '@elastic/eui'; import { docLinks } from '../../../shared/doc_links'; @@ -25,11 +25,12 @@ describe('EmptyEngineOverview', () => { }); it('renders', () => { - expect(wrapper.find('h1').text()).toEqual('Engine setup'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine setup'); }); it('renders a documentation link', () => { - expect(wrapper.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); + const header = wrapper.find(EuiPageHeader).dive().children().dive(); + expect(header.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index f505f08a3531a..d48664febb5f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -7,13 +7,7 @@ import React from 'react'; -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiPageContentBody, - EuiTitle, - EuiButton, -} from '@elastic/eui'; +import { EuiPageHeader, EuiPageContentBody, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -23,25 +17,20 @@ import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_cre export const EmptyEngineOverview: React.FC = () => { return ( <> - - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.engine.overview.empty.heading', { - defaultMessage: 'Engine setup', - })} -

-
-
- + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.overview.empty.headingAction', { defaultMessage: 'View documentation' } )} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts index df8ed920e88df..decadba1092d3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -78,7 +78,9 @@ describe('EngineOverviewLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('pollForOverviewMetrics', () => { it('fetches data and calls onPollingSuccess', async () => { mount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx index 638c8b0da87ce..d51bef3b29761 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx @@ -11,13 +11,15 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiPageHeader } from '@elastic/eui'; + import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewMetrics } from './engine_overview_metrics'; describe('EngineOverviewMetrics', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('h1').text()).toEqual('Engine overview'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine overview'); }); it('renders an unavailable prompt if engine data is still indexing', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index c60cf70f435c5..8d376ff1971a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -23,15 +23,11 @@ export const EngineOverviewMetrics: React.FC = () => { return ( <> - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.engine.overview.heading', { - defaultMessage: 'Engine overview', - })} -

-
-
+ {apiLogsUnavailable ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx index 14772375c9bd4..a737174477177 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx @@ -6,7 +6,7 @@ */ import '../../../../__mocks__/kea.mock'; -import { mockTelemetryActions } from '../../../../__mocks__'; +import { setMockValues, mockTelemetryActions } from '../../../../__mocks__'; import React from 'react'; @@ -17,30 +17,47 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import { EmptyState } from './'; describe('EmptyState', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); - }); - - describe('CTA Button', () => { + describe('when the user can manage/create engines', () => { let wrapper: ShallowWrapper; - let prompt: ShallowWrapper; - let button: ShallowWrapper; - beforeEach(() => { + beforeAll(() => { + setMockValues({ myRole: { canManageEngines: true } }); wrapper = shallow(); - prompt = wrapper.find(EuiEmptyPrompt).dive(); - button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]'); }); - it('sends telemetry on create first engine click', () => { - button.simulate('click'); - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + it('renders a prompt to create an engine', () => { + expect(wrapper.find('[data-test-subj="AdminEmptyEnginesPrompt"]')).toHaveLength(1); + }); + + describe('create engine button', () => { + let prompt: ShallowWrapper; + let button: ShallowWrapper; + + beforeAll(() => { + prompt = wrapper.find(EuiEmptyPrompt).dive(); + button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]'); + }); + + it('sends telemetry on create first engine click', () => { + button.simulate('click'); + expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + }); + + it('sends a user to engine creation', () => { + expect(button.prop('to')).toEqual('/engine_creation'); + }); + }); + }); + + describe('when the user cannot manage/create engines', () => { + beforeAll(() => { + setMockValues({ myRole: { canManageEngines: false } }); }); - it('sends a user to engine creation', () => { - expect(button.prop('to')).toEqual('/engine_creation'); + it('renders a prompt to contact the App Search admin', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="NonAdminEmptyEnginesPrompt"]')).toHaveLength(1); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx index fc77e2f2511e0..df5a057e5d9c6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx @@ -7,14 +7,15 @@ import React from 'react'; -import { useActions } from 'kea'; +import { useValues, useActions } from 'kea'; import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../../shared/telemetry'; +import { AppLogic } from '../../../app_logic'; import { ENGINE_CREATION_PATH } from '../../../routes'; import { EnginesOverviewHeader } from './header'; @@ -22,6 +23,9 @@ import { EnginesOverviewHeader } from './header'; import './empty_state.scss'; export const EmptyState: React.FC = () => { + const { + myRole: { canManageEngines }, + } = useValues(AppLogic); const { sendAppSearchTelemetry } = useActions(TelemetryLogic); return ( @@ -29,45 +33,71 @@ export const EmptyState: React.FC = () => { - - - - } - titleSize="l" - body={ -

- -

- } - actions={ - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'create_first_engine_button', - }) - } - > - - - } - /> + {canManageEngines ? ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.title', { + defaultMessage: 'Create your first engine', + })} + + } + titleSize="l" + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.description1', { + defaultMessage: + 'An App Search engine stores the documents for your search experience.', + })} +

+ } + actions={ + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'create_first_engine_button', + }) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta', + { defaultMessage: 'Create an engine' } + )} + + } + /> + ) : ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.title', { + defaultMessage: 'No engines available', + })} + + } + body={ +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.emptyState.nonAdmin.description', + { + defaultMessage: + 'Contact your App Search administrator to either create or grant you access to an engine.', + } + )} +

+ } + /> + )}
); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx index 5ccd2c552ef02..3ffe2f3d43a77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/enterprise_search_url.mock'; import { mockTelemetryActions } from '../../../../__mocks__'; @@ -16,13 +15,16 @@ import { shallow } from 'enzyme'; import { EnginesOverviewHeader } from './'; describe('EnginesOverviewHeader', () => { + const wrapper = shallow() + .dive() + .children() + .dive(); + it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find('h1')).toHaveLength(1); + expect(wrapper.find('h1').text()).toEqual('Engines overview'); }); it('renders a launch app search button that sends telemetry on click', () => { - const wrapper = shallow(); const button = wrapper.find('[data-test-subj="launchButton"]'); expect(button.prop('href')).toBe('http://localhost:3002/as'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx index 290270c08258c..fb3b771850a31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx @@ -9,15 +9,8 @@ import React from 'react'; import { useActions } from 'kea'; -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, - EuiButton, - EuiButtonProps, - EuiLinkProps, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; import { TelemetryLogic } from '../../../../shared/telemetry'; @@ -25,39 +18,31 @@ import { TelemetryLogic } from '../../../../shared/telemetry'; export const EnginesOverviewHeader: React.FC = () => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const buttonProps = { - fill: true, - iconType: 'popout', - 'data-test-subj': 'launchButton', - href: getAppSearchUrl(), - target: '_blank', - onClick: () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }), - } as EuiButtonProps & EuiLinkProps; - return ( - - - -

- -

-
-
- - - - - -
+ + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'header_launch_button', + }) + } + data-test-subj="launchButton" + > + {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { + defaultMessage: 'Launch App Search', + })} + , + ]} + /> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index 5a3f730940760..3ca039907932e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -41,6 +41,7 @@ describe('EnginesOverview', () => { }, metaEnginesLoading: false, hasPlatinumLicense: false, + myRole: { canManageEngines: false }, }; const actions = { loadEngines: jest.fn(), @@ -90,61 +91,72 @@ describe('EnginesOverview', () => { setMockValues(valuesWithEngines); }); - it('renders and calls the engines API', async () => { + it('renders and calls the engines API', () => { const wrapper = shallow(); expect(wrapper.find(EnginesTable)).toHaveLength(1); expect(actions.loadEngines).toHaveBeenCalled(); }); - it('renders a create engine button which takes users to the create engine page', () => { - const wrapper = shallow(); + describe('when the user can manage/create engines', () => { + it('renders a create engine button which takes users to the create engine page', () => { + setMockValues({ + ...valuesWithEngines, + myRole: { canManageEngines: true }, + }); + const wrapper = shallow(); - expect( - wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') - ).toEqual('/engine_creation'); + expect( + wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') + ).toEqual('/engine_creation'); + }); }); - describe('when user has a platinum license', () => { - let wrapper: ShallowWrapper; - - beforeEach(() => { + describe('when the account has a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines call', () => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, }); - wrapper = shallow(); - }); + const wrapper = shallow(); - it('renders a 2nd meta engines table ', async () => { expect(wrapper.find(EnginesTable)).toHaveLength(2); - }); - - it('makes a 2nd meta engines call', () => { expect(actions.loadMetaEngines).toHaveBeenCalled(); }); - it('renders a create engine button which takes users to the create meta engine page', () => { - expect( - wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to') - ).toEqual('/meta_engine_creation'); - }); - - it('contains an EuiEmptyPrompt that takes users to the create meta when metaEngines is empty', () => { - setMockValues({ - ...valuesWithEngines, - hasPlatinumLicense: true, - metaEngines: [], + describe('when the user can manage/create engines', () => { + it('renders a create engine button which takes users to the create meta engine page', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + myRole: { canManageEngines: true }, + }); + const wrapper = shallow(); + + expect( + wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to') + ).toEqual('/meta_engine_creation'); }); - wrapper = shallow(); - const metaEnginesTable = wrapper.find(EnginesTable).last().dive(); - const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive(); - expect( - emptyPrompt - .find('[data-test-subj="appSearchMetaEnginesEmptyStateCreationButton"]') - .prop('to') - ).toEqual('/meta_engine_creation'); + describe('when metaEngines is empty', () => { + it('contains an EuiEmptyPrompt that takes users to the create meta engine page', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + myRole: { canManageEngines: true }, + metaEngines: [], + }); + const wrapper = shallow(); + const metaEnginesTable = wrapper.find(EnginesTable).last().dive(); + const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive(); + + expect( + emptyPrompt + .find('[data-test-subj="appSearchMetaEnginesEmptyStateCreationButton"]') + .prop('to') + ).toEqual('/meta_engine_creation'); + }); + }); }); }); @@ -152,7 +164,7 @@ describe('EnginesOverview', () => { const getTablePagination = (wrapper: ShallowWrapper) => wrapper.find(EnginesTable).prop('pagination'); - it('passes down page data from the API', async () => { + it('passes down page data from the API', () => { const wrapper = shallow(); const pagination = getTablePagination(wrapper); @@ -160,7 +172,7 @@ describe('EnginesOverview', () => { expect(pagination.pageIndex).toEqual(0); }); - it('re-polls the API on page change', async () => { + it('re-polls the API on page change', () => { const wrapper = shallow(); setMockValues({ @@ -178,7 +190,7 @@ describe('EnginesOverview', () => { expect(getTablePagination(wrapper).pageIndex).toEqual(50); }); - it('calls onPagination handlers', async () => { + it('calls onPagination handlers', () => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 8297fb490ee04..baf275fbe6c2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -25,6 +25,7 @@ import { LicensingLogic } from '../../../shared/licensing'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; import { EngineIcon, MetaEngineIcon } from '../../icons'; import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; @@ -44,6 +45,9 @@ import './engines_overview.scss'; export const EnginesOverview: React.FC = () => { const { hasPlatinumLicense } = useValues(LicensingLogic); + const { + myRole: { canManageEngines }, + } = useValues(AppLogic); const { dataLoading, @@ -91,14 +95,16 @@ export const EnginesOverview: React.FC = () => { - - {CREATE_AN_ENGINE_BUTTON_LABEL} - + {canManageEngines && ( + + {CREATE_AN_ENGINE_BUTTON_LABEL} + + )} @@ -126,14 +132,16 @@ export const EnginesOverview: React.FC = () => { - - {CREATE_A_META_ENGINE_BUTTON_LABEL} - + {canManageEngines && ( + + {CREATE_A_META_ENGINE_BUTTON_LABEL} + + )} @@ -149,13 +157,15 @@ export const EnginesOverview: React.FC = () => { title={

{META_ENGINE_EMPTY_PROMPT_TITLE}

} body={

{META_ENGINE_EMPTY_PROMPT_DESCRIPTION}

} actions={ - - {CREATE_A_META_ENGINE_BUTTON_LABEL} - + canManageEngines && ( + + {CREATE_A_META_ENGINE_BUTTON_LABEL} + + ) } /> } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx index 66f9c3c3c473d..fc37c3543af56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx @@ -51,16 +51,21 @@ describe('EnginesTable', () => { onDeleteEngine, }; + const resetMocks = () => { + jest.clearAllMocks(); + setMockValues({ + myRole: { + canManageEngines: false, + }, + }); + }; + describe('basic table', () => { let wrapper: ReactWrapper; let table: ReactWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - // LicensingLogic - hasPlatinumLicense: false, - }); + resetMocks(); wrapper = mountWithIntl(); table = wrapper.find(EuiBasicTable); }); @@ -101,11 +106,7 @@ describe('EnginesTable', () => { describe('loading', () => { it('passes the loading prop', () => { - jest.clearAllMocks(); - setMockValues({ - // LicensingLogic - hasPlatinumLicense: false, - }); + resetMocks(); const wrapper = mountWithIntl(); expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true); @@ -114,6 +115,7 @@ describe('EnginesTable', () => { describe('noItemsMessage', () => { it('passes the noItemsMessage prop', () => { + resetMocks(); const wrapper = mountWithIntl(); expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); }); @@ -121,11 +123,7 @@ describe('EnginesTable', () => { describe('language field', () => { beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - // LicensingLogic - hasPlatinumLicense: false, - }); + resetMocks(); }); it('renders language when available', () => { @@ -181,29 +179,27 @@ describe('EnginesTable', () => { }); describe('actions', () => { - it('will hide the action buttons if the user does not have a platinum license', () => { - jest.clearAllMocks(); - setMockValues({ - // LicensingLogic - hasPlatinumLicense: false, - }); + it('will hide the action buttons if the user cannot manage/delete engines', () => { + resetMocks(); const wrapper = shallow(); const tableRow = wrapper.find(EuiTableRow).first(); expect(tableRow.find(EuiIcon)).toHaveLength(0); }); - describe('when user has a platinum license', () => { + describe('when the user can manage/delete engines', () => { let wrapper: ReactWrapper; let tableRow: ReactWrapper; let actions: ReactWrapper; beforeEach(() => { - jest.clearAllMocks(); + resetMocks(); setMockValues({ - // LicensingLogic - hasPlatinumLicense: true, + myRole: { + canManageEngines: true, + }, }); + wrapper = mountWithIntl(); tableRow = wrapper.find(EuiTableRow).first(); actions = tableRow.find(EuiIcon); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx index 3b9b6e6c6a778..624e212c72702 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx @@ -19,9 +19,9 @@ import { i18n } from '@kbn/i18n'; import { FormattedNumber } from '@kbn/i18n/react'; import { KibanaLogic } from '../../../shared/kibana'; -import { LicensingLogic } from '../../../shared/licensing'; import { EuiLinkTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; import { UNIVERSAL_LANGUAGE } from '../../constants'; import { ENGINE_PATH } from '../../routes'; import { generateEncodedPath } from '../../utils/encode_path_params'; @@ -52,7 +52,9 @@ export const EnginesTable: React.FC = ({ }) => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); const { navigateToUrl } = useValues(KibanaLogic); - const { hasPlatinumLicense } = useValues(LicensingLogic); + const { + myRole: { canManageEngines }, + } = useValues(AppLogic); const generateEncodedEnginePath = (engineName: string) => generateEncodedPath(ENGINE_PATH, { engineName }); @@ -177,6 +179,7 @@ export const EnginesTable: React.FC = ({ ), type: 'icon', icon: 'trash', + color: 'danger', onClick: (engine) => { if ( window.confirm( @@ -199,7 +202,7 @@ export const EnginesTable: React.FC = ({ ], }; - if (hasPlatinumLicense) { + if (canManageEngines) { columns.push(actionsColumn); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 594584d9ba101..5e268cc0fd214 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -10,7 +10,6 @@ import React, { useState } from 'react'; import { EuiSpacer, EuiPageHeader, - EuiPageHeaderSection, EuiTitle, EuiPageContentBody, EuiPageContent, @@ -86,13 +85,7 @@ export const Library: React.FC = () => { return ( <> - - - -

Library

-
-
-
+ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.test.tsx index 124edb6871453..fe022391d76b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.test.tsx @@ -63,15 +63,6 @@ describe('LogRetentionCallout', () => { expect(wrapper.find('.euiCallOutHeader__title').text()).toEqual('API Logs have been disabled.'); }); - it('does not render a settings link if the user cannot manage settings', () => { - setMockValues({ myRole: { canManageLogSettings: false }, logRetention: { api: DISABLED } }); - const wrapper = mountWithIntl(); - - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiLink)).toHaveLength(0); - expect(wrapper.find('p')).toHaveLength(0); - }); - it('does not render if log retention is enabled', () => { setMockValues({ ...values, logRetention: { api: { enabled: true } } }); const wrapper = shallow(); @@ -100,5 +91,12 @@ describe('LogRetentionCallout', () => { expect(actions.fetchLogRetention).not.toHaveBeenCalled(); }); + + it('does not fetch log retention data if the user does not have access to log settings', () => { + setMockValues({ ...values, logRetention: null, myRole: { canManageLogSettings: false } }); + shallow(); + + expect(actions.fetchLogRetention).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx index 235d977793161..1fd1a9a79b225 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx @@ -40,7 +40,7 @@ export const LogRetentionCallout: React.FC = ({ type }) => { const hasLogRetention = logRetention !== null; useEffect(() => { - if (!hasLogRetention) fetchLogRetention(); + if (!hasLogRetention && canManageLogSettings) fetchLogRetention(); }, []); const logRetentionSettings = logRetention?.[type]; @@ -72,24 +72,22 @@ export const LogRetentionCallout: React.FC = ({ type }) => { ) } > - {canManageLogSettings && ( -

- - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsLinkText', - { defaultMessage: 'visit your settings' } - )} - - ), - }} - /> -

- )} +

+ + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.logRetention.callout.description.manageSettingsLinkText', + { defaultMessage: 'visit your settings' } + )} + + ), + }} + /> +

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.test.tsx index 854a9f1d8d162..6b5693e4c301e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.test.tsx @@ -19,7 +19,10 @@ import { LogRetentionOptions, LogRetentionMessage } from '../'; import { LogRetentionTooltip } from './'; describe('LogRetentionTooltip', () => { - const values = { logRetention: {} }; + const values = { + logRetention: {}, + myRole: { canManageLogSettings: true }, + }; const actions = { fetchLogRetention: jest.fn() }; beforeEach(() => { @@ -53,7 +56,7 @@ describe('LogRetentionTooltip', () => { }); it('does not render if log retention is not available', () => { - setMockValues({ logRetention: null }); + setMockValues({ ...values, logRetention: null }); const wrapper = mount(); expect(wrapper.isEmptyRender()).toBe(true); @@ -61,14 +64,21 @@ describe('LogRetentionTooltip', () => { describe('on mount', () => { it('fetches log retention data when not already loaded', () => { - setMockValues({ logRetention: null }); + setMockValues({ ...values, logRetention: null }); shallow(); expect(actions.fetchLogRetention).toHaveBeenCalled(); }); it('does not fetch log retention data if it has already been loaded', () => { - setMockValues({ logRetention: {} }); + setMockValues({ ...values, logRetention: {} }); + shallow(); + + expect(actions.fetchLogRetention).not.toHaveBeenCalled(); + }); + + it('does not fetch log retention data if the user does not have access to log settings', () => { + setMockValues({ ...values, logRetention: null, myRole: { canManageLogSettings: false } }); shallow(); expect(actions.fetchLogRetention).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.tsx index bf074ba0272f2..ac701d4cc067b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_tooltip.tsx @@ -12,7 +12,9 @@ import { useValues, useActions } from 'kea'; import { EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { LogRetentionLogic, LogRetentionMessage, LogRetentionOptions } from '../'; +import { AppLogic } from '../../../app_logic'; + +import { LogRetentionLogic, LogRetentionMessage, LogRetentionOptions } from '../index'; interface Props { type: LogRetentionOptions; @@ -21,11 +23,14 @@ interface Props { export const LogRetentionTooltip: React.FC = ({ type, position = 'bottom' }) => { const { fetchLogRetention } = useActions(LogRetentionLogic); const { logRetention } = useValues(LogRetentionLogic); + const { + myRole: { canManageLogSettings }, + } = useValues(AppLogic); const hasLogRetention = logRetention !== null; useEffect(() => { - if (!hasLogRetention) fetchLogRetention(); + if (!hasLogRetention && canManageLogSettings) fetchLogRetention(); }, []); return hasLogRetention ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index 19bd2af50aad9..57f3f46fed92e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -107,21 +107,6 @@ describe('LogRetentionLogic', () => { }); }); - describe('setLogRetentionUpdating', () => { - describe('isLogRetentionUpdating', () => { - it('sets isLogRetentionUpdating to true', () => { - mount(); - - LogRetentionLogic.actions.setLogRetentionUpdating(); - - expect(LogRetentionLogic.values).toEqual({ - ...DEFAULT_VALUES, - isLogRetentionUpdating: true, - }); - }); - }); - }); - describe('clearLogRetentionUpdating', () => { describe('isLogRetentionUpdating', () => { it('resets isLogRetentionUpdating to false', () => { @@ -177,7 +162,9 @@ describe('LogRetentionLogic', () => { }); }); }); + }); + describe('listeners', () => { describe('saveLogRetention', () => { beforeEach(() => { mount(); @@ -264,11 +251,61 @@ describe('LogRetentionLogic', () => { LogRetentionOptions.Analytics ); }); + + it('will call saveLogRetention if NOT already enabled', () => { + mount({ + logRetention: { + [LogRetentionOptions.Analytics]: { + enabled: false, + }, + }, + }); + jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); + + LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.Analytics); + + expect(LogRetentionLogic.actions.saveLogRetention).toHaveBeenCalledWith( + LogRetentionOptions.Analytics, + true + ); + }); + + it('will do nothing if logRetention option is not yet set', () => { + mount({ + logRetention: {}, + }); + jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); + jest.spyOn(LogRetentionLogic.actions, 'setOpenedModal'); + + LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.API); + + expect(LogRetentionLogic.actions.saveLogRetention).not.toHaveBeenCalled(); + expect(LogRetentionLogic.actions.setOpenedModal).not.toHaveBeenCalled(); + }); }); describe('fetchLogRetention', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + describe('isLogRetentionUpdating', () => { + it('sets isLogRetentionUpdating to true', () => { + mount({ + isLogRetentionUpdating: false, + }); + + LogRetentionLogic.actions.fetchLogRetention(); + + expect(LogRetentionLogic.values).toEqual({ + ...DEFAULT_VALUES, + isLogRetentionUpdating: true, + }); + }); + }); + it('will call an API endpoint and update log retention', async () => { mount(); + jest.spyOn(LogRetentionLogic.actions, 'clearLogRetentionUpdating'); jest .spyOn(LogRetentionLogic.actions, 'updateLogRetention') .mockImplementationOnce(() => {}); @@ -276,14 +313,14 @@ describe('LogRetentionLogic', () => { http.get.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.fetchLogRetention(); - expect(LogRetentionLogic.values.isLogRetentionUpdating).toBe(true); + jest.runAllTimers(); + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/log_settings'); - await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); - expect(LogRetentionLogic.values.isLogRetentionUpdating).toBe(false); + expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); }); it('handles errors', async () => { @@ -292,50 +329,12 @@ describe('LogRetentionLogic', () => { http.get.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.fetchLogRetention(); + jest.runAllTimers(); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); }); - - it('does not run if isLogRetentionUpdating is true, preventing duplicate fetches', async () => { - mount({ isLogRetentionUpdating: true }); - - LogRetentionLogic.actions.fetchLogRetention(); - - expect(http.get).not.toHaveBeenCalled(); - }); - }); - - it('will call saveLogRetention if NOT already enabled', () => { - mount({ - logRetention: { - [LogRetentionOptions.Analytics]: { - enabled: false, - }, - }, - }); - jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); - - LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.Analytics); - - expect(LogRetentionLogic.actions.saveLogRetention).toHaveBeenCalledWith( - LogRetentionOptions.Analytics, - true - ); - }); - - it('will do nothing if logRetention option is not yet set', () => { - mount({ - logRetention: {}, - }); - jest.spyOn(LogRetentionLogic.actions, 'saveLogRetention'); - jest.spyOn(LogRetentionLogic.actions, 'setOpenedModal'); - - LogRetentionLogic.actions.toggleLogRetention(LogRetentionOptions.API); - - expect(LogRetentionLogic.actions.saveLogRetention).not.toHaveBeenCalled(); - expect(LogRetentionLogic.actions.setOpenedModal).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.ts index ec078842dab55..aa1be3f8cdc64 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.ts @@ -14,7 +14,6 @@ import { LogRetentionOptions, LogRetention, LogRetentionServer } from './types'; import { convertLogRetentionFromServerToClient } from './utils/convert_log_retention'; interface LogRetentionActions { - setLogRetentionUpdating(): void; clearLogRetentionUpdating(): void; closeModals(): void; fetchLogRetention(): void; @@ -36,7 +35,6 @@ interface LogRetentionValues { export const LogRetentionLogic = kea>({ path: ['enterprise_search', 'app_search', 'log_retention_logic'], actions: () => ({ - setLogRetentionUpdating: true, clearLogRetentionUpdating: true, closeModals: true, fetchLogRetention: true, @@ -57,7 +55,7 @@ export const LogRetentionLogic = kea false, closeModals: () => false, - setLogRetentionUpdating: () => true, + fetchLogRetention: () => true, toggleLogRetention: () => true, }, ], @@ -71,12 +69,10 @@ export const LogRetentionLogic = kea ({ - fetchLogRetention: async () => { - if (values.isLogRetentionUpdating) return; // Prevent duplicate calls to the API + fetchLogRetention: async (_, breakpoint) => { + await breakpoint(100); // Prevents duplicate calls to the API (e.g., when a tooltip & callout are on the same page) try { - actions.setLogRetentionUpdating(); - const { http } = HttpLogic.values; const response = await http.get('/api/app_search/log_settings'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx index d701ee37a1658..a3dbf7259975b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx @@ -20,9 +20,7 @@ import { EuiFieldText, EuiPageContent, EuiPageHeader, - EuiPageHeaderSection, EuiSpacer, - EuiText, EuiTitle, EuiButton, } from '@elastic/eui'; @@ -78,15 +76,16 @@ export const MetaEngineCreation: React.FC = () => { return (
- - - -

{META_ENGINE_CREATION_TITLE}

-
- {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} - {META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION} -
-
+ + {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} +
+ {META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION} + + } + /> { }} > - {META_ENGINE_CREATION_FORM_TITLE} +

{META_ENGINE_CREATION_FORM_TITLE}

@@ -140,14 +139,16 @@ export const MetaEngineCreation: React.FC = () => { }} /> - {selectedIndexedEngineNames.length > maxEnginesPerMetaEngine && ( - + <> + + + )} { return ( <> - - - -

{SETTINGS_TITLE}

-
-
-
+ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 8b4f0f70039d3..a04707ad48338 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -55,4 +55,4 @@ export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; -export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api-logs`; +export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api_logs`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts index 82fc00923202f..34e67acc870ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -82,4 +82,14 @@ describe('AppLogic', () => { expect(AppLogic.values.account.canCreatePersonalSources).toEqual(true); }); }); + + describe('setOrgName', () => { + it('sets property', () => { + const NAME = 'new name'; + mount(DEFAULT_INITIAL_APP_DATA); + AppLogic.actions.setOrgName(NAME); + + expect(AppLogic.values.organization.name).toEqual(NAME); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index b81f538bd4709..26e1d7fbb93fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -22,6 +22,7 @@ interface AppValues extends WorkplaceSearchInitialData { interface AppActions { initializeAppData(props: InitialAppData): InitialAppData; setContext(isOrganization: boolean): boolean; + setOrgName(name: string): string; setSourceRestriction(canCreatePersonalSources: boolean): boolean; } @@ -36,6 +37,7 @@ export const AppLogic = kea>({ isFederatedAuth, }), setContext: (isOrganization) => isOrganization, + setOrgName: (name: string) => name, setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources, }, reducers: { @@ -61,6 +63,10 @@ export const AppLogic = kea>({ emptyOrg, { initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.organization || emptyOrg, + setOrgName: (state, name) => ({ + ...state, + name, + }), }, ], account: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx index a7a788b48789a..2cd47f1c1b597 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx @@ -9,13 +9,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonEmpty } from '@elastic/eui'; - import { externalUrl } from '../../../shared/enterprise_search_url'; +import { WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; import { WorkplaceSearchHeaderActions } from './'; describe('WorkplaceSearchHeaderActions', () => { + const ENT_SEARCH_URL = 'http://localhost:3002'; + it('does not render without an Enterprise Search URL set', () => { const wrapper = shallow(); @@ -23,22 +24,32 @@ describe('WorkplaceSearchHeaderActions', () => { }); it('renders a link to the personal dashboard', () => { - externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; - + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).first().prop('href')).toEqual( - 'http://localhost:3002/ws/sources' + expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]').prop('to')).toEqual( + '/p/sources' ); + expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]')).toHaveLength(0); }); it('renders a link to the search application', () => { - externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; - + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).last().prop('href')).toEqual( + expect(wrapper.find('[data-test-subj="HeaderSearchButton"]').prop('href')).toEqual( 'http://localhost:3002/ws/search' ); }); + + it('renders an MVP link back to the legacy dashboard on the MVP page', () => { + window.history.pushState({}, 'Overview', WORKPLACE_SEARCH_URL_PREFIX); + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]').prop('href')).toEqual( + `${ENT_SEARCH_URL}/ws/sources` + ); + expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx index 95d7920ae0435..7d594ce66aea1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx @@ -10,20 +10,46 @@ import React from 'react'; import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { externalUrl, getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { NAV } from '../../constants'; +import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; +import { NAV, WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; +import { PERSONAL_SOURCES_PATH } from '../../routes'; export const WorkplaceSearchHeaderActions: React.FC = () => { if (!externalUrl.enterpriseSearchUrl) return null; + const isMVP = window.location.pathname.endsWith(WORKPLACE_SEARCH_URL_PREFIX); + + const personalDashboardMVPButton = ( + + {NAV.PERSONAL_DASHBOARD} + + ); + + const personalDashboardButton = ( + + {NAV.PERSONAL_DASHBOARD} + + ); + return ( + {isMVP ? personalDashboardMVPButton : personalDashboardButton} - - {NAV.PERSONAL_DASHBOARD} - - - - + {NAV.SEARCH} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx index d9c2d70e78c08..d37af01287c46 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -47,8 +47,7 @@ describe('ContentSection', () => { /> ); - expect(wrapper.find(EuiSpacer).first().prop('size')).toEqual('s'); - expect(wrapper.find(EuiSpacer)).toHaveLength(2); + expect(wrapper.find(EuiSpacer)).toHaveLength(1); expect(wrapper.find('.header')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx index d9a4ed7eee8b8..f0b86e0cc925b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -30,7 +30,6 @@ export const ContentSection: React.FC = ({ description, action, headerChildren, - headerSpacer, testSubj, }) => (
@@ -38,10 +37,9 @@ export const ContentSection: React.FC = ({ <> {headerChildren} - {headerSpacer && } )} {children} - +
); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 64431a800487f..30f5009ac0b3c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -6,6 +6,9 @@ */ import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { Location } from 'history'; import { useActions, useValues } from 'kea'; @@ -31,6 +34,7 @@ import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { + const { search } = useLocation() as Location; const { initializeAddSource, setAddSourceStep, @@ -83,9 +87,9 @@ export const AddSource: React.FC = (props) => { const saveCustomSuccess = () => setAddSourceStep(AddSourceSteps.SaveCustomStep); const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); - const goToFormSourceCreated = (sourceName: string) => { + const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl( - `${getSourcesPath(SOURCE_ADDED_PATH, isOrganization)}/?name=${sourceName}` + `${getSourcesPath(SOURCE_ADDED_PATH, isOrganization)}${search}` ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index d0ab40399fa59..6c60cd74a9c9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -20,7 +20,7 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; +import { ADD_GITHUB_PATH, SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { CustomSource } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -55,10 +55,12 @@ describe('AddSourceLogic', () => { sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, newCustomSource: {} as CustomSource, + oauthConfigCompleted: false, currentServiceType: '', githubOrganizations: [], selectedGithubOrganizationsMap: {} as OrganizationsMap, selectedGithubOrganizations: [], + preContentSourceId: '', }; const sourceConnectData = { @@ -182,6 +184,12 @@ describe('AddSourceLogic', () => { expect(AddSourceLogic.values.selectedGithubOrganizationsMap).toEqual({ foo: true }); }); + it('setPreContentSourceId', () => { + AddSourceLogic.actions.setPreContentSourceId('123'); + + expect(AddSourceLogic.values.preContentSourceId).toEqual('123'); + }); + it('setButtonNotLoading', () => { AddSourceLogic.actions.setButtonNotLoading(); @@ -317,6 +325,34 @@ describe('AddSourceLogic', () => { expect(navigateToUrl).toHaveBeenCalledWith(getSourcesPath(SOURCES_PATH, false)); }); + it('redirects to oauth config when preContentSourceId is present', async () => { + const preContentSourceId = 'id123'; + const setPreContentSourceIdSpy = jest.spyOn( + AddSourceLogic.actions, + 'setPreContentSourceId' + ); + + http.get.mockReturnValue( + Promise.resolve({ + ...response, + hasConfigureStep: true, + preContentSourceId, + }) + ); + AddSourceLogic.actions.saveSourceParams(queryString); + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/sources/create', { + query: { + ...params, + kibana_host: '', + }, + }); + + await nextTick(); + + expect(setPreContentSourceIdSpy).toHaveBeenCalledWith(preContentSourceId); + expect(navigateToUrl).toHaveBeenCalledWith(`${ADD_GITHUB_PATH}/configure${queryString}`); + }); + it('handles error', async () => { http.get.mockReturnValue(Promise.reject('this is an error')); @@ -440,13 +476,14 @@ describe('AddSourceLogic', () => { describe('getPreContentSourceConfigData', () => { it('calls API and sets values', async () => { + mount({ preContentSourceId: '123' }); const setPreContentSourceConfigDataSpy = jest.spyOn( AddSourceLogic.actions, 'setPreContentSourceConfigData' ); http.get.mockReturnValue(Promise.resolve(config)); - AddSourceLogic.actions.getPreContentSourceConfigData('123'); + AddSourceLogic.actions.getPreContentSourceConfigData(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/pre_sources/123'); await nextTick(); @@ -456,7 +493,7 @@ describe('AddSourceLogic', () => { it('handles error', async () => { http.get.mockReturnValue(Promise.reject('this is an error')); - AddSourceLogic.actions.getPreContentSourceConfigData('123'); + AddSourceLogic.actions.getPreContentSourceConfigData(); await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); @@ -616,7 +653,8 @@ describe('AddSourceLogic', () => { }); it('getPreContentSourceConfigData', () => { - AddSourceLogic.actions.getPreContentSourceConfigData('123'); + mount({ preContentSourceId: '123' }); + AddSourceLogic.actions.getPreContentSourceConfigData(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/account/pre_sources/123'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index e1f554d87551d..ed63f82764f7e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -22,7 +22,7 @@ import { KibanaLogic } from '../../../../../shared/kibana'; import { parseQueryParams } from '../../../../../shared/query_params'; import { AppLogic } from '../../../../app_logic'; import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; +import { SOURCES_PATH, ADD_GITHUB_PATH, getSourcesPath } from '../../../../routes'; import { CustomSource } from '../../../../types'; import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; @@ -74,6 +74,7 @@ export interface AddSourceActions { setSourceIndexPermissionsValue(indexPermissionsValue: boolean): boolean; setCustomSourceData(data: CustomSource): CustomSource; setPreContentSourceConfigData(data: PreContentSourceResponse): PreContentSourceResponse; + setPreContentSourceId(preContentSourceId: string): string; setSelectedGithubOrganizations(option: string): string; resetSourceState(): void; createContentSource( @@ -92,7 +93,7 @@ export interface AddSourceActions { successCallback: (oauthUrl: string) => void ): { serviceType: string; successCallback(oauthUrl: string): void }; getSourceReConnectData(sourceId: string): { sourceId: string }; - getPreContentSourceConfigData(preContentSourceId: string): { preContentSourceId: string }; + getPreContentSourceConfigData(): void; setButtonNotLoading(): void; } @@ -144,6 +145,8 @@ interface AddSourceValues { githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; selectedGithubOrganizations: string[]; + preContentSourceId: string; + oauthConfigCompleted: boolean; } interface PreContentSourceResponse { @@ -181,6 +184,7 @@ export const AddSourceLogic = kea indexPermissionsValue, setCustomSourceData: (data: CustomSource) => data, setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, + setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, setSelectedGithubOrganizations: (option: string) => option, getSourceConfigData: (serviceType: string) => ({ serviceType }), getSourceConnectData: (serviceType: string, successCallback: (oauthUrl: string) => string) => ({ @@ -188,7 +192,7 @@ export const AddSourceLogic = kea ({ sourceId }), - getPreContentSourceConfigData: (preContentSourceId: string) => ({ preContentSourceId }), + getPreContentSourceConfigData: () => true, saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ isUpdating, successCallback, @@ -344,6 +348,20 @@ export const AddSourceLogic = kea ({}), }, ], + preContentSourceId: [ + '', + { + setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, + setPreContentSourceConfigData: () => '', + resetSourceState: () => '', + }, + ], + oauthConfigCompleted: [ + false, + { + setPreContentSourceConfigData: () => true, + }, + ], }, selectors: ({ selectors }) => ({ selectedGithubOrganizations: [ @@ -407,8 +425,9 @@ export const AddSourceLogic = kea { + getPreContentSourceConfigData: async () => { const { isOrganization } = AppLogic.values; + const { preContentSourceId } = values; const route = isOrganization ? `/api/workplace_search/org/pre_sources/${preContentSourceId}` : `/api/workplace_search/account/pre_sources/${preContentSourceId}`; @@ -480,12 +499,24 @@ export const AddSourceLogic = kea = ({ name, onFormCreated, header }) => { - const { search } = useLocation() as Location; - - const { preContentSourceId } = (parseQueryParams(search) as unknown) as OauthQueryParams; const [formLoading, setFormLoading] = useState(false); const { @@ -58,7 +48,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const checkboxOptions = githubOrganizations.map((item) => ({ id: item, label: item })); useEffect(() => { - getPreContentSourceConfigData(preContentSourceId); + getPreContentSourceConfigData(); }, []); const handleChange = (option: string) => setSelectedGithubOrganizations(option); @@ -101,6 +91,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea return ( <> {header} + {sectionLoading ? : configfieldsForm} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index dd756a51fded3..712be15e7c046 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -176,7 +176,7 @@ export const CONFIG_CUSTOM_BUTTON = i18n.translate( export const CONFIG_OAUTH_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configOauth.label', { - defaultMessage: 'Complete connection', + defaultMessage: 'Select GitHub organizations to sync', } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 2d9e5580c6f40..338eda0214ea2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -18,7 +18,7 @@ import { IconType, } from '@elastic/eui'; -import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; interface OnboardingCardProps { @@ -49,15 +49,15 @@ export const OnboardingCard: React.FC = ({ }); const completeButton = actionPath ? ( - + {actionTitle} - + ) : ( {actionTitle} ); const incompleteButton = actionPath ? ( - + {actionTitle} ) : ( @@ -66,7 +66,7 @@ export const OnboardingCard: React.FC = ({ return ( - + { }); return ( - + - + @@ -158,16 +158,17 @@ export const OrgNameOnboarding: React.FC = () => { - - +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 525035030b8cc..d1f0f6a030421 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiFlexGrid } from '@elastic/eui'; +import { EuiFlexGrid, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -35,45 +35,46 @@ export const OrganizationStats: React.FC = () => { defaultMessage="Usage statistics" /> } - headerSpacer="m" > - - + + + {!isFederatedAuth && ( + <> + + + )} - count={sourcesCount} - actionPath={SOURCES_PATH} - /> - {!isFederatedAuth && ( - <> - - - - )} - - + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 62b96442b9ba0..8bda7c2843b9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -41,7 +41,7 @@ export const RecentActivity: React.FC = () => { return ( - + {activityFeed.length > 0 ? ( <> {activityFeed.map((props: FeedActivity, index) => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx index 136901f840b89..9b134b511b34e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx @@ -24,6 +24,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, layout="horizontal" title={title} titleSize="xs" + display="plain" description={ {count} @@ -36,6 +37,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, layout="horizontal" title={title} titleSize="xs" + display="plain" description={ {count} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts index ad552ff8f5a41..e07adbde15939 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -17,6 +17,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; +import { AppLogic } from '../../app_logic'; import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; import { ORG_SETTINGS_CONNECTORS_PATH } from '../../routes'; import { Connector } from '../../types'; @@ -150,6 +151,7 @@ export const SettingsLogic = kea> const response = await http.put(route, { body }); actions.setUpdatedName(response); setSuccessMessage(ORG_UPDATED_MESSAGE); + AppLogic.actions.setOrgName(name); } catch (e) { flashAPIErrors(e); } diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 73bf17195a5db..efe3186f97805 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -327,7 +327,15 @@ describe('queryEventsBySavedObject', () => { asApiResponse({ hits: { hits: [], - total: { value: 0 }, + total: { relation: 'eq', value: 0 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, }, }) ); @@ -391,11 +399,13 @@ describe('queryEventsBySavedObject', () => { }, }, "size": 10, - "sort": Object { - "@timestamp": Object { - "order": "asc", + "sort": Array [ + Object { + "@timestamp": Object { + "order": "asc", + }, }, - }, + ], }, "index": "index-name", "track_total_hits": true, @@ -408,7 +418,15 @@ describe('queryEventsBySavedObject', () => { asApiResponse({ hits: { hits: [], - total: { value: 0 }, + total: { relation: 'eq', value: 0 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, }, }) ); @@ -474,11 +492,13 @@ describe('queryEventsBySavedObject', () => { }, }, "size": 10, - "sort": Object { - "@timestamp": Object { - "order": "asc", + "sort": Array [ + Object { + "@timestamp": Object { + "order": "asc", + }, }, - }, + ], }, "index": "index-name", "track_total_hits": true, @@ -491,7 +511,15 @@ describe('queryEventsBySavedObject', () => { asApiResponse({ hits: { hits: [], - total: { value: 0 }, + total: { relation: 'eq', value: 0 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, }, }) ); @@ -507,7 +535,7 @@ describe('queryEventsBySavedObject', () => { expect(query).toMatchObject({ index: 'index-name', body: { - sort: { 'event.end': { order: 'desc' } }, + sort: [{ 'event.end': { order: 'desc' } }], }, }); }); @@ -517,7 +545,15 @@ describe('queryEventsBySavedObject', () => { asApiResponse({ hits: { hits: [], - total: { value: 0 }, + total: { relation: 'eq', value: 0 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, }, }) ); @@ -591,11 +627,13 @@ describe('queryEventsBySavedObject', () => { }, }, "size": 10, - "sort": Object { - "@timestamp": Object { - "order": "asc", + "sort": Array [ + Object { + "@timestamp": Object { + "order": "asc", + }, }, - }, + ], }, "index": "index-name", "track_total_hits": true, @@ -608,7 +646,15 @@ describe('queryEventsBySavedObject', () => { asApiResponse({ hits: { hits: [], - total: { value: 0 }, + total: { relation: 'eq', value: 0 }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 0, + total: 0, + skipped: 0, }, }) ); @@ -690,11 +736,13 @@ describe('queryEventsBySavedObject', () => { }, }, "size": 10, - "sort": Object { - "@timestamp": Object { - "order": "asc", + "sort": Array [ + Object { + "@timestamp": Object { + "order": "asc", + }, }, - }, + ], }, "index": "index-name", "track_total_hits": true, diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index f025801a45955..5d7be2278d55d 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -7,9 +7,10 @@ import { Subject } from 'rxjs'; import { bufferTime, filter as rxFilter, switchMap } from 'rxjs/operators'; -import { reject, isUndefined } from 'lodash'; +import { reject, isUndefined, isNumber } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, ElasticsearchClient } from 'src/core/server'; +import { estypes } from '@elastic/elasticsearch'; import { EsContext } from '.'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; @@ -135,14 +136,13 @@ export class ClusterClientAdapter { } public async doesIndexTemplateExist(name: string): Promise { - let result; try { const esClient = await this.elasticsearchClientPromise; - result = (await esClient.indices.existsTemplate({ name })).body; + const { body } = await esClient.indices.existsTemplate({ name }); + return body as boolean; } catch (err) { throw new Error(`error checking existance of index template: ${err.message}`); } - return result as boolean; } public async createIndexTemplate(name: string, template: Record): Promise { @@ -162,20 +162,16 @@ export class ClusterClientAdapter { } public async doesAliasExist(name: string): Promise { - let result; try { const esClient = await this.elasticsearchClientPromise; - result = (await esClient.indices.existsAlias({ name })).body; + const { body } = await esClient.indices.existsAlias({ name }); + return body as boolean; } catch (err) { throw new Error(`error checking existance of initial index: ${err.message}`); } - return result as boolean; } - public async createIndex( - name: string, - body: string | Record = {} - ): Promise { + public async createIndex(name: string, body: Record = {}): Promise { try { const esClient = await this.elasticsearchClientPromise; await esClient.indices.create({ @@ -228,64 +224,67 @@ export class ClusterClientAdapter { }); throw err; } - const body = { - size: perPage, - from: (page - 1) * perPage, - sort: { [sort_field]: { order: sort_order } }, - query: { - bool: { - filter: dslFilterQuery, - must: reject( - [ - { - nested: { - path: 'kibana.saved_objects', - query: { - bool: { - must: [ - { - term: { - 'kibana.saved_objects.rel': { - value: SAVED_OBJECT_REL_PRIMARY, - }, - }, - }, - { - term: { - 'kibana.saved_objects.type': { - value: type, - }, - }, - }, - { - terms: { - // default maximum of 65,536 terms, configurable by index.max_terms_count - 'kibana.saved_objects.id': ids, - }, - }, - namespaceQuery, - ], + const musts: estypes.QueryContainer[] = [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.rel': { + value: SAVED_OBJECT_REL_PRIMARY, }, }, }, - }, - start && { - range: { - '@timestamp': { - gte: start, + { + term: { + 'kibana.saved_objects.type': { + value: type, + }, }, }, - }, - end && { - range: { - '@timestamp': { - lte: end, + { + terms: { + // default maximum of 65,536 terms, configurable by index.max_terms_count + 'kibana.saved_objects.id': ids, }, }, - }, - ], - isUndefined - ), + namespaceQuery, + ], + }, + }, + }, + }, + ]; + if (start) { + musts.push({ + range: { + '@timestamp': { + gte: start, + }, + }, + }); + } + if (end) { + musts.push({ + range: { + '@timestamp': { + lte: end, + }, + }, + }); + } + + const body: estypes.SearchRequest['body'] = { + size: perPage, + from: (page - 1) * perPage, + sort: [{ [sort_field]: { order: sort_order } }], + query: { + bool: { + filter: dslFilterQuery, + must: reject(musts, isUndefined), }, }, }; @@ -295,7 +294,7 @@ export class ClusterClientAdapter { body: { hits: { hits, total }, }, - } = await esClient.search({ + } = await esClient.search({ index, track_total_hits: true, body, @@ -303,8 +302,8 @@ export class ClusterClientAdapter { return { page, per_page: perPage, - total: total.value, - data: hits.map((hit: { _source: unknown }) => hit._source) as IValidatedEvent[], + total: isNumber(total) ? total : total.value, + data: hits.map((hit) => hit._source), }; } catch (err) { throw new Error( diff --git a/x-pack/plugins/file_upload/server/analyze_file.tsx b/x-pack/plugins/file_upload/server/analyze_file.tsx index 394573eb0cca5..2239697083492 100644 --- a/x-pack/plugins/file_upload/server/analyze_file.tsx +++ b/x-pack/plugins/file_upload/server/analyze_file.tsx @@ -6,13 +6,7 @@ */ import { IScopedClusterClient } from 'kibana/server'; -import { - AnalysisResult, - FormattedOverrides, - InputData, - InputOverrides, - FindFileStructureResponse, -} from '../common'; +import { AnalysisResult, FormattedOverrides, InputData, InputOverrides } from '../common'; export async function analyzeFile( client: IScopedClusterClient, @@ -20,9 +14,7 @@ export async function analyzeFile( overrides: InputOverrides ): Promise { overrides.explain = overrides.explain === undefined ? 'true' : overrides.explain; - const { - body, - } = await client.asInternalUser.textStructure.findStructure({ + const { body } = await client.asInternalUser.textStructure.findStructure({ body: data, ...overrides, }); @@ -31,6 +23,7 @@ export async function analyzeFile( return { ...(hasOverrides && { overrides: reducedOverrides }), + // @ts-expect-error type incompatible with FindFileStructureResponse results: body, }; } diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 55c32802c3334..388aebed9a85b 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -1633,7 +1633,21 @@ { "$ref": "#/paths/~1setup/post/parameters/0" } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + } + } + } } }, "/agents/{agentId}": { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9461927bb09b8..227faffdac489 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -1038,6 +1038,14 @@ paths: operationId: post-epm-delete-pkgkey parameters: - $ref: '#/paths/~1setup/post/parameters/0' + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean '/agents/{agentId}': parameters: - schema: diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml index 43937aa153f50..85d8615a9eb4b 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml @@ -89,3 +89,11 @@ delete: operationId: post-epm-delete-pkgkey parameters: - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean diff --git a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts index 0350c47816f6d..bb117dd5c5071 100644 --- a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts +++ b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts @@ -17,13 +17,19 @@ export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { } else { return false; } - if (agent.unenrollment_started_at || agent.unenrolled_at) return false; - if (!agent.local_metadata.elastic.agent.upgradeable) return false; + if (agent.unenrollment_started_at || agent.unenrolled_at) { + return false; + } + if (!agent.local_metadata.elastic.agent.upgradeable) { + return false; + } // make sure versions are only the number before comparison const agentVersionNumber = semverCoerce(agentVersion); if (!agentVersionNumber) throw new Error('agent version is invalid'); const kibanaVersionNumber = semverCoerce(kibanaVersion); if (!kibanaVersionNumber) throw new Error('kibana version is invalid'); - return semverLt(agentVersionNumber, kibanaVersionNumber); + const isAgentLessThanKibana = semverLt(agentVersionNumber, kibanaVersionNumber); + + return isAgentLessThanKibana; } diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 5c385f938a69e..1984de79a6357 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -26,6 +26,9 @@ export interface FleetConfigType { host?: string; ca_sha256?: string; }; + fleet_server?: { + hosts?: string[]; + }; agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }; diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 35b123b2c64ea..0629a67f0d8d3 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -36,8 +36,7 @@ export type AgentActionType = | 'UNENROLL' | 'UPGRADE' | 'SETTINGS' - // INTERNAL* actions are mean to interupt long polling calls these actions will not be distributed to the agent - | 'INTERNAL_POLICY_REASSIGN'; + | 'POLICY_REASSIGN'; export interface NewAgentAction { type: AgentActionType; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 7e5b799e484d6..6e984b2d0b3da 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -66,9 +66,13 @@ export interface FullAgentPolicy { [key: string]: any; }; }; - fleet?: { - kibana: FullAgentPolicyKibanaConfig; - }; + fleet?: + | { + hosts: string[]; + } + | { + kibana: FullAgentPolicyKibanaConfig; + }; inputs: FullAgentPolicyInput[]; revision?: number; agent?: { diff --git a/x-pack/plugins/fleet/common/types/models/settings.ts b/x-pack/plugins/fleet/common/types/models/settings.ts index bb345a67bec41..d6932f9a4d83f 100644 --- a/x-pack/plugins/fleet/common/types/models/settings.ts +++ b/x-pack/plugins/fleet/common/types/models/settings.ts @@ -8,9 +8,11 @@ import type { SavedObjectAttributes } from 'src/core/public'; export interface BaseSettings { + has_seen_add_data_notice?: boolean; + fleet_server_hosts: string[]; + // TODO remove as part of https://github.com/elastic/kibana/issues/94303 kibana_urls: string[]; kibana_ca_sha256?: string; - has_seen_add_data_notice?: boolean; } export interface Settings extends BaseSettings { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 93cbb8369a3b1..4616e92925b3a 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -121,8 +121,13 @@ export interface PostBulkAgentUnenrollRequest { }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUnenrollResponse {} +export type PostBulkAgentUnenrollResponse = Record< + Agent['id'], + { + success: boolean; + error?: string; + } +>; export interface PostAgentUpgradeRequest { params: { @@ -141,8 +146,14 @@ export interface PostBulkAgentUpgradeRequest { version: string; }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUpgradeResponse {} + +export type PostBulkAgentUpgradeResponse = Record< + Agent['id'], + { + success: boolean; + error?: string; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUpgradeResponse {} @@ -164,12 +175,13 @@ export interface PostBulkAgentReassignRequest { }; } -export interface PostBulkAgentReassignResponse { - [key: string]: { +export type PostBulkAgentReassignResponse = Record< + Agent['id'], + { success: boolean; - error?: Error; - }; -} + error?: string; + } +>; export interface GetOneAgentEventsRequest { params: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx index 6f1adfc8cf9c1..a46e49233cc99 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx @@ -13,6 +13,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import type { EnrollmentAPIKey } from '../../../types'; interface Props { + fleetServerHosts: string[]; kibanaUrl: string; apiKey: EnrollmentAPIKey; kibanaCASha256?: string; @@ -23,14 +24,32 @@ const CommandCode = styled.pre({ overflow: 'scroll', }); +function getfleetServerHostsEnrollArgs(apiKey: EnrollmentAPIKey, fleetServerHosts: string[]) { + return `--url=${fleetServerHosts[0]} --enrollment-token=${apiKey.api_key}`; +} + +function getKibanaUrlEnrollArgs( + apiKey: EnrollmentAPIKey, + kibanaUrl: string, + kibanaCASha256?: string +) { + return `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${ + kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' + }`; +} + export const ManualInstructions: React.FunctionComponent = ({ kibanaUrl, apiKey, kibanaCASha256, + fleetServerHosts, }) => { - const enrollArgs = `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${ - kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' - }`; + const fleetServerHostsNotEmpty = fleetServerHosts.length > 0; + + const enrollArgs = fleetServerHostsNotEmpty + ? getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts) + : // TODO remove as part of https://github.com/elastic/kibana/issues/94303 + getKibanaUrlEnrollArgs(apiKey, kibanaUrl, kibanaCASha256); const linuxMacCommand = `./elastic-agent install -f ${enrollArgs}`; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx deleted file mode 100644 index 146f40cd75d49..0000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiSpacer, - EuiButton, - EuiFlyoutFooter, - EuiForm, - EuiFormRow, - EuiComboBox, - EuiCodeEditor, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiText } from '@elastic/eui'; -import { safeLoad } from 'js-yaml'; - -import { - useComboInput, - useStartServices, - useGetSettings, - useInput, - sendPutSettings, -} from '../hooks'; -import { useGetOutputs, sendPutOutput } from '../hooks/use_request/outputs'; -import { isDiffPathProtocol } from '../../../../common/'; - -const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm; - -interface Props { - onClose: () => void; -} - -function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { - const [isLoading, setIsloading] = React.useState(false); - const { notifications } = useStartServices(); - const kibanaUrlsInput = useComboInput([], (value) => { - if (value.length === 0) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', { - defaultMessage: 'At least one URL is required', - }), - ]; - } - if (value.some((v) => !v.match(URL_REGEX))) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlError', { - defaultMessage: 'Invalid URL', - }), - ]; - } - if (isDiffPathProtocol(value)) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', { - defaultMessage: 'Protocol and path must be the same for each URL', - }), - ]; - } - }); - const elasticsearchUrlInput = useComboInput([], (value) => { - if (value.some((v) => !v.match(URL_REGEX))) { - return [ - i18n.translate('xpack.fleet.settings.elasticHostError', { - defaultMessage: 'Invalid URL', - }), - ]; - } - }); - - const additionalYamlConfigInput = useInput('', (value) => { - try { - safeLoad(value); - return; - } catch (error) { - return [ - i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', { - defaultMessage: 'Invalid YAML: {reason}', - values: { reason: error.message }, - }), - ]; - } - }); - return { - isLoading, - onSubmit: async () => { - if ( - !kibanaUrlsInput.validate() || - !elasticsearchUrlInput.validate() || - !additionalYamlConfigInput.validate() - ) { - return; - } - - try { - setIsloading(true); - if (!outputId) { - throw new Error('Unable to load outputs'); - } - const outputResponse = await sendPutOutput(outputId, { - hosts: elasticsearchUrlInput.value, - config_yaml: additionalYamlConfigInput.value, - }); - if (outputResponse.error) { - throw outputResponse.error; - } - const settingsResponse = await sendPutSettings({ - kibana_urls: kibanaUrlsInput.value, - }); - if (settingsResponse.error) { - throw settingsResponse.error; - } - notifications.toasts.addSuccess( - i18n.translate('xpack.fleet.settings.success.message', { - defaultMessage: 'Settings saved', - }) - ); - setIsloading(false); - onSuccess(); - } catch (error) { - setIsloading(false); - notifications.toasts.addError(error, { - title: 'Error', - }); - } - }, - inputs: { - kibanaUrls: kibanaUrlsInput, - elasticsearchUrl: elasticsearchUrlInput, - additionalYamlConfig: additionalYamlConfigInput, - }, - }; -} - -export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { - const settingsRequest = useGetSettings(); - const settings = settingsRequest?.data?.item; - const outputsRequest = useGetOutputs(); - const output = outputsRequest.data?.items?.[0]; - const { inputs, onSubmit, isLoading } = useSettingsForm(output?.id, onClose); - - useEffect(() => { - if (output) { - inputs.elasticsearchUrl.setValue(output.hosts || []); - inputs.additionalYamlConfig.setValue( - output.config_yaml || - `# YAML settings here will be added to the Elasticsearch output section of each policy` - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [output]); - - useEffect(() => { - if (settings) { - inputs.kibanaUrls.setValue(settings.kibana_urls); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings]); - - const body = ( - - -

- -

-
- - - - - - - - - - - - - - - - - - - - - - -
- ); - - return ( - - - -

- -

-
-
- {body} - - - - - - - - - - - - - - -
- ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx new file mode 100644 index 0000000000000..8bef32916452f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalFooter, + EuiModalBody, + EuiCallOut, + EuiButton, + EuiButtonEmpty, + EuiBasicTable, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import type { EuiBasicTableProps } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface SettingsConfirmModalProps { + changes: Array<{ + type: 'elasticsearch' | 'fleet_server'; + direction: 'removed' | 'added'; + urls: string[]; + }>; + onConfirm: () => void; + onClose: () => void; +} + +type Change = SettingsConfirmModalProps['changes'][0]; + +const TABLE_COLUMNS: EuiBasicTableProps['columns'] = [ + { + name: i18n.translate('xpack.fleet.settingsConfirmModal.fieldLabel', { + defaultMessage: 'Field', + }), + field: 'label', + render: (_, item) => getLabel(item), + width: '180px', + }, + { + field: 'urls', + name: i18n.translate('xpack.fleet.settingsConfirmModal.valueLabel', { + defaultMessage: 'Value', + }), + render: (_, item) => { + return ( + + {item.urls.map((url) => ( +
{url}
+ ))} +
+ ); + }, + }, +]; + +function getLabel(change: Change) { + if (change.type === 'elasticsearch' && change.direction === 'removed') { + return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchRemovedLabel', { + defaultMessage: 'Elasticsearch hosts (old)', + }); + } + + if (change.type === 'elasticsearch' && change.direction === 'added') { + return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchAddedLabel', { + defaultMessage: 'Elasticsearch hosts (new)', + }); + } + + if (change.type === 'fleet_server' && change.direction === 'removed') { + return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerRemovedLabel', { + defaultMessage: 'Fleet Server hosts (old)', + }); + } + + if (change.type === 'fleet_server' && change.direction === 'added') { + return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerAddedLabel', { + defaultMessage: 'Fleet Server hosts (new)', + }); + } + + return i18n.translate('xpack.fleet.settingsConfirmModal.defaultChangeLabel', { + defaultMessage: 'Unknown setting', + }); +} + +export const SettingsConfirmModal = React.memo( + ({ changes, onConfirm, onClose }) => { + const hasESChanges = changes.some((change) => change.type === 'elasticsearch'); + const hasFleetServerChanges = changes.some((change) => change.type === 'fleet_server'); + + return ( + + + + + + + + + + } + color="warning" + iconType="alert" + > + + {hasFleetServerChanges && ( +

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

+ )} + + {hasESChanges && ( +

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

+ )} +
+
+ + {changes.length > 0 && ( + <> + + + + )} +
+ + + + + + + + + +
+ ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx new file mode 100644 index 0000000000000..faf8707f2efc1 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -0,0 +1,439 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiSpacer, + EuiButton, + EuiFlyoutFooter, + EuiForm, + EuiFormRow, + EuiComboBox, + EuiCode, + EuiCodeEditor, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +import { safeLoad } from 'js-yaml'; + +import { + useComboInput, + useStartServices, + useGetSettings, + useInput, + sendPutSettings, +} from '../../hooks'; +import { useGetOutputs, sendPutOutput } from '../../hooks/use_request/outputs'; +import { isDiffPathProtocol } from '../../../../../common/'; + +import { SettingsConfirmModal } from './confirm_modal'; +import type { SettingsConfirmModalProps } from './confirm_modal'; + +const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm; + +interface Props { + onClose: () => void; +} + +function isSameArrayValue(arrayA: string[] = [], arrayB: string[] = []) { + return arrayA.length === arrayB.length && arrayA.every((val, index) => val === arrayB[index]); +} + +function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { + const [isLoading, setIsloading] = React.useState(false); + const { notifications } = useStartServices(); + const kibanaUrlsInput = useComboInput([], (value) => { + if (value.length === 0) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', { + defaultMessage: 'At least one URL is required', + }), + ]; + } + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + if (isDiffPathProtocol(value)) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', { + defaultMessage: 'Protocol and path must be the same for each URL', + }), + ]; + } + }); + const fleetServerHostsInput = useComboInput([], (value) => { + // TODO enable as part of https://github.com/elastic/kibana/issues/94303 + // if (value.length === 0) { + // return [ + // i18n.translate('xpack.fleet.settings.fleetServerHostsEmptyError', { + // defaultMessage: 'At least one URL is required', + // }), + // ]; + // } + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.fleetServerHostsError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + if (value.length && isDiffPathProtocol(value)) { + return [ + i18n.translate('xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError', { + defaultMessage: 'Protocol and path must be the same for each URL', + }), + ]; + } + }); + + const elasticsearchUrlInput = useComboInput([], (value) => { + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.elasticHostError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + }); + + const additionalYamlConfigInput = useInput('', (value) => { + try { + safeLoad(value); + return; + } catch (error) { + return [ + i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', { + defaultMessage: 'Invalid YAML: {reason}', + values: { reason: error.message }, + }), + ]; + } + }); + + const validate = useCallback(() => { + if ( + !kibanaUrlsInput.validate() || + !fleetServerHostsInput.validate() || + !elasticsearchUrlInput.validate() || + !additionalYamlConfigInput.validate() + ) { + return false; + } + + return true; + }, [kibanaUrlsInput, fleetServerHostsInput, elasticsearchUrlInput, additionalYamlConfigInput]); + + return { + isLoading, + validate, + submit: async () => { + try { + setIsloading(true); + if (!outputId) { + throw new Error('Unable to load outputs'); + } + const outputResponse = await sendPutOutput(outputId, { + hosts: elasticsearchUrlInput.value, + config_yaml: additionalYamlConfigInput.value, + }); + if (outputResponse.error) { + throw outputResponse.error; + } + const settingsResponse = await sendPutSettings({ + kibana_urls: kibanaUrlsInput.value, + fleet_server_hosts: fleetServerHostsInput.value, + }); + if (settingsResponse.error) { + throw settingsResponse.error; + } + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.settings.success.message', { + defaultMessage: 'Settings saved', + }) + ); + setIsloading(false); + onSuccess(); + } catch (error) { + setIsloading(false); + notifications.toasts.addError(error, { + title: 'Error', + }); + } + }, + inputs: { + fleetServerHosts: fleetServerHostsInput, + kibanaUrls: kibanaUrlsInput, + elasticsearchUrl: elasticsearchUrlInput, + additionalYamlConfig: additionalYamlConfigInput, + }, + }; +} + +export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { + const settingsRequest = useGetSettings(); + const settings = settingsRequest?.data?.item; + const outputsRequest = useGetOutputs(); + const output = outputsRequest.data?.items?.[0]; + const { inputs, submit, validate, isLoading } = useSettingsForm(output?.id, onClose); + + const [isConfirmModalVisible, setConfirmModalVisible] = React.useState(false); + + const onSubmit = useCallback(() => { + if (validate()) { + setConfirmModalVisible(true); + } + }, [validate, setConfirmModalVisible]); + + const onConfirm = useCallback(() => { + setConfirmModalVisible(false); + submit(); + }, [submit]); + + const onConfirmModalClose = useCallback(() => { + setConfirmModalVisible(false); + }, [setConfirmModalVisible]); + + useEffect(() => { + if (output) { + inputs.elasticsearchUrl.setValue(output.hosts || []); + inputs.additionalYamlConfig.setValue(output.config_yaml || ''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [output]); + + useEffect(() => { + if (settings) { + inputs.kibanaUrls.setValue([...settings.kibana_urls]); + inputs.fleetServerHosts.setValue([...settings.fleet_server_hosts]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings]); + + const isUpdated = React.useMemo(() => { + if (!settings || !output) { + return false; + } + return ( + !isSameArrayValue(settings.kibana_urls, inputs.kibanaUrls.value) || + !isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value) || + !isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value) || + (output.config_yaml || '') !== inputs.additionalYamlConfig.value + ); + }, [settings, inputs, output]); + + const changes = React.useMemo(() => { + if (!settings || !output || !isConfirmModalVisible) { + return []; + } + + const tmpChanges: SettingsConfirmModalProps['changes'] = []; + if (!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value)) { + tmpChanges.push( + { + type: 'elasticsearch', + direction: 'removed', + urls: output.hosts || [], + }, + { + type: 'elasticsearch', + direction: 'added', + urls: inputs.elasticsearchUrl.value, + } + ); + } + + if (!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value)) { + tmpChanges.push( + { + type: 'fleet_server', + direction: 'removed', + urls: settings.fleet_server_hosts, + }, + { + type: 'fleet_server', + direction: 'added', + urls: inputs.fleetServerHosts.value, + } + ); + } + + return tmpChanges; + }, [settings, inputs, output, isConfirmModalVisible]); + + const body = settings && ( + + +

+ +

+
+ + + outputs, + }} + /> + + + + + + + ), + }} + /> + } + {...inputs.fleetServerHosts.formRowProps} + > + + + + + {/* // TODO remove as part of https://github.com/elastic/kibana/issues/94303 */} + + + + + + + + + + + +
+ ); + + return ( + <> + {isConfirmModalVisible && ( + + )} + + + +

+ +

+
+
+ {body} + + + + + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + +
+ + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index ba6367a861e9d..4a7e738ec540a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -125,7 +125,7 @@ export const DefaultLayout: React.FunctionComponent = ({ setIsSettingsFlyoutOpen(true)}> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 4925f60f19e26..0ca6b223b3492 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -56,6 +56,7 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { apiKey={apiKey.data.item} kibanaUrl={kibanaUrl} kibanaCASha256={kibanaCASha256} + fleetServerHosts={settings.data?.item?.fleet_server_hosts || []} /> ), }, diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 8bad868b813ac..0178b801f4d2f 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -65,6 +65,11 @@ export const config: PluginConfigDescriptor = { host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), }), + fleet_server: schema.maybe( + schema.object({ + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), + }) + ), agentPolicyRolloutRateLimitIntervalMs: schema.number({ defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS, }), diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index e6188a83c49e9..5ac264e29f079 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -308,26 +308,26 @@ export const postBulkAgentsReassignHandler: RequestHandler< const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asInternalUser; + const agentOptions = Array.isArray(request.body.agents) + ? { agentIds: request.body.agents } + : { kuery: request.body.agents }; try { const results = await AgentService.reassignAgents( soClient, esClient, - Array.isArray(request.body.agents) - ? { agentIds: request.body.agents } - : { kuery: request.body.agents }, + agentOptions, request.body.policy_id ); - const body: PostBulkAgentReassignResponse = results.items.reduce((acc, so) => { - return { - ...acc, - [so.id]: { - success: !so.error, - error: so.error || undefined, - }, + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, }; + return acc; }, {}); + return response.ok({ body }); } catch (error) { return defaultIngestErrorHandler({ error, response }); diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index 558a9a8afbb0b..1505955215515 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -60,11 +60,17 @@ export const postBulkAgentsUnenrollHandler: RequestHandler< : { kuery: request.body.agents }; try { - await AgentService.unenrollAgents(soClient, esClient, { + const results = await AgentService.unenrollAgents(soClient, esClient, { ...agentOptions, force: request.body?.force, }); - const body: PostBulkAgentUnenrollResponse = {}; + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, + }; + return acc; + }, {}); return response.ok({ body }); } catch (error) { diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index b8af265883091..52f62037f61e6 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -99,9 +99,15 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< version, force, }; - await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); + const results = await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, + }; + return acc; + }, {}); - const body: PostBulkAgentUpgradeResponse = {}; return response.ok({ body }); } catch (error) { return defaultIngestErrorHandler({ error, response }); diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index a928c80f6dd81..c684c05003612 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -96,6 +96,7 @@ export const getListHandler: RequestHandler = async (context, request, response) // Query backing indices to extract data stream dataset, namespace, and type values const { body: { + // @ts-expect-error @elastic/elasticsearch aggregations are not typed aggregations: { dataset, namespace, type }, }, } = await esClient.search({ diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 3ac951f7987f8..f0d6e68427361 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -310,13 +310,20 @@ export const installPackageByUploadHandler: RequestHandler< }; export const deletePackageHandler: RequestHandler< - TypeOf + TypeOf, + undefined, + TypeOf > = async (context, request, response) => { try { const { pkgkey } = request.params; const savedObjectsClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - const res = await removeInstallation({ savedObjectsClient, pkgkey, esClient }); + const res = await removeInstallation({ + savedObjectsClient, + pkgkey, + esClient, + force: request.body?.force, + }); const body: DeletePackageResponse = { response: res, }; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index e5f0537a8c27a..87ca9782ab698 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -58,9 +58,11 @@ const getSavedObjectTypes = ( }, mappings: { properties: { + fleet_server_hosts: { type: 'keyword' }, + has_seen_add_data_notice: { type: 'boolean', index: false }, + // TODO remove as part of https://github.com/elastic/kibana/issues/94303 kibana_urls: { type: 'keyword' }, kibana_ca_sha256: { type: 'keyword' }, - has_seen_add_data_notice: { type: 'boolean', index: false }, }, }, migrations: { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 515d2b1195638..56e76130538cf 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -171,6 +171,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', @@ -206,6 +207,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', @@ -242,6 +244,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2cafe2fe57c01..357b9150407ef 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -706,12 +706,20 @@ class AgentPolicyService { } catch (error) { throw new Error('Default settings is not setup'); } - if (!settings.kibana_urls || !settings.kibana_urls.length) - throw new Error('kibana_urls is missing'); - - fullAgentPolicy.fleet = { - kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), - }; + if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) { + fullAgentPolicy.fleet = { + hosts: settings.fleet_server_hosts, + }; + } // TODO remove as part of https://github.com/elastic/kibana/issues/94303 + else { + if (!settings.kibana_urls || !settings.kibana_urls.length) + throw new Error('kibana_urls is missing'); + + fullAgentPolicy.fleet = { + hosts: settings.kibana_urls, + kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), + }; + } } return fullAgentPolicy; } diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 3be69893ab252..bcece7283270b 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -359,7 +359,7 @@ export async function getLatestConfigChangeAction( search: policyId, searchFields: ['policy_id'], sortField: 'created_at', - sortOrder: 'DESC', + sortOrder: 'desc', }); if (res.saved_objects[0]) { diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts index 7dc19f63a5adb..8810dd6ff1263 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.ts @@ -39,7 +39,7 @@ import { getAgentPolicyActionByIds, } from '../actions'; import { appContextService } from '../../app_context'; -import { getAgentById, updateAgent } from '../crud'; +import { updateAgent } from '../crud'; import { toPromiseAbortable, AbortError, createRateLimiter } from './rxjs_utils'; @@ -262,25 +262,6 @@ export function agentCheckinStateNewActionsFactory() { return EMPTY; } - const hasConfigReassign = newActions.some( - (action) => action.type === 'INTERNAL_POLICY_REASSIGN' - ); - if (hasConfigReassign) { - return from(getAgentById(esClient, agent.id)).pipe( - concatMap((refreshedAgent) => { - if (!refreshedAgent.policy_id) { - throw new Error('Agent does not have a policy assigned'); - } - const newAgentPolicy$ = getOrCreateAgentPolicyObservable(refreshedAgent.policy_id); - return newAgentPolicy$; - }), - rateLimiter(), - concatMap((policyAction) => - createAgentActionFromPolicyAction(soClient, esClient, agent, policyAction) - ) - ); - } - return of(newActions); }), filter((data) => data !== undefined), diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 22e9f559c56b8..ecf18430da668 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -6,11 +6,10 @@ */ import Boom from '@hapi/boom'; -import type { SearchResponse, MGetResponse, GetResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; -import type { ESSearchResponse } from '../../../../../../typings/elasticsearch'; -import type { AgentSOAttributes, Agent, ListWithKuery } from '../../types'; +import type { AgentSOAttributes, Agent, BulkActionResult, ListWithKuery } from '../../types'; import { appContextService, agentPolicyService } from '../../services'; import type { FleetServerAgent } from '../../../common'; import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; @@ -69,22 +68,23 @@ export type GetAgentsOptions = }; export async function getAgents(esClient: ElasticsearchClient, options: GetAgentsOptions) { - let initialResults = []; - + let agents: Agent[] = []; if ('agentIds' in options) { - initialResults = await getAgentsById(esClient, options.agentIds); + agents = await getAgentsById(esClient, options.agentIds); } else if ('kuery' in options) { - initialResults = ( + agents = ( await getAllAgentsByKuery(esClient, { kuery: options.kuery, showInactive: options.showInactive ?? false, }) ).agents; } else { - throw new IngestManagerError('Cannot get agents'); + throw new IngestManagerError( + 'Either options.agentIds or options.kuery are required to get agents' + ); } - return initialResults; + return agents; } export async function getAgentsByKuery( @@ -119,7 +119,7 @@ export async function getAgentsByKuery( const kueryNode = _joinFilters(filters); const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {}; - const res = await esClient.search>({ + const res = await esClient.search({ index: AGENTS_INDEX, from: (page - 1) * perPage, size: perPage, @@ -139,7 +139,7 @@ export async function getAgentsByKuery( return { agents, - total: res.body.hits.total.value, + total: (res.body.hits.total as estypes.TotalHits).value, page, perPage, }; @@ -182,13 +182,14 @@ export async function countInactiveAgents( track_total_hits: true, body, }); + // @ts-expect-error value is number | TotalHits return res.body.hits.total.value; } export async function getAgentById(esClient: ElasticsearchClient, agentId: string) { const agentNotFoundError = new AgentNotFoundError(`Agent ${agentId} not found`); try { - const agentHit = await esClient.get>({ + const agentHit = await esClient.get({ index: AGENTS_INDEX, id: agentId, }); @@ -207,11 +208,18 @@ export async function getAgentById(esClient: ElasticsearchClient, agentId: strin } } -async function getAgentDocuments( +export function isAgentDocument( + maybeDocument: any +): maybeDocument is estypes.MultiGetHit { + return '_id' in maybeDocument && '_source' in maybeDocument; +} + +export type ESAgentDocumentResult = estypes.MultiGetHit; +export async function getAgentDocuments( esClient: ElasticsearchClient, agentIds: string[] -): Promise>> { - const res = await esClient.mget>({ +): Promise { + const res = await esClient.mget({ index: AGENTS_INDEX, body: { docs: agentIds.map((_id) => ({ _id })) }, }); @@ -221,14 +229,16 @@ async function getAgentDocuments( export async function getAgentsById( esClient: ElasticsearchClient, - agentIds: string[], - options: { includeMissing?: boolean } = { includeMissing: false } + agentIds: string[] ): Promise { const allDocs = await getAgentDocuments(esClient, agentIds); - const agentDocs = options.includeMissing - ? allDocs - : allDocs.filter((res) => res._id && res._source); - const agents = agentDocs.map((doc) => searchHitToAgent(doc)); + const agents = allDocs.reduce((results, doc) => { + if (isAgentDocument(doc)) { + results.push(searchHitToAgent(doc)); + } + + return results; + }, []); return agents; } @@ -237,7 +247,7 @@ export async function getAgentByAccessAPIKeyId( esClient: ElasticsearchClient, accessAPIKeyId: string ): Promise { - const res = await esClient.search>({ + const res = await esClient.search({ index: AGENTS_INDEX, q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`, }); @@ -276,7 +286,7 @@ export async function bulkUpdateAgents( agentId: string; data: Partial; }> -) { +): Promise<{ items: BulkActionResult[] }> { if (updateData.length === 0) { return { items: [] }; } @@ -299,10 +309,11 @@ export async function bulkUpdateAgents( }); return { - items: res.body.items.map((item: { update: { _id: string; error?: Error } }) => ({ - id: item.update._id, - success: !item.update.error, - error: item.update.error, + items: res.body.items.map((item: estypes.BulkResponseItemContainer) => ({ + id: item.update!._id as string, + success: !item.update!.error, + // @ts-expect-error ErrorCause is not assignable to Error + error: item.update!.error as Error, })), }; } diff --git a/x-pack/plugins/fleet/server/services/agents/helpers.ts b/x-pack/plugins/fleet/server/services/agents/helpers.ts index 89f37a01a6b00..c003d3d546e83 100644 --- a/x-pack/plugins/fleet/server/services/agents/helpers.ts +++ b/x-pack/plugins/fleet/server/services/agents/helpers.ts @@ -5,25 +5,26 @@ * 2.0. */ -import type { GetResponse, SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; -import type { ESSearchHit } from '../../../../../../typings/elasticsearch'; +import type { SearchHit } from '../../../../../../typings/elasticsearch'; import type { Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; type FleetServerAgentESResponse = - | GetResponse - | ESSearchHit - | SearchResponse['hits']['hits'][0]; + | estypes.MultiGetHit + | estypes.SearchResponse['hits']['hits'][0] + | SearchHit; export function searchHitToAgent(hit: FleetServerAgentESResponse): Agent { + // @ts-expect-error @elastic/elasticsearch MultiGetHit._source is optional return { id: hit._id, ...hit._source, - policy_revision: hit._source.policy_revision_idx, + policy_revision: hit._source?.policy_revision_idx, current_error_events: [], access_api_key: undefined, status: undefined, - packages: hit._source.packages ?? [], + packages: hit._source?.packages ?? [], }; } diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts index 987f461587233..f040ba57c38be 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -47,7 +47,6 @@ describe('reassignAgent (singular)', () => { expect(esClient.update).toBeCalledTimes(1); const calledWith = esClient.update.mock.calls[0]; expect(calledWith[0]?.id).toBe(agentInUnmanagedDoc._id); - // @ts-expect-error expect(calledWith[0]?.body?.doc).toHaveProperty('policy_id', unmanagedAgentPolicySO.id); }); @@ -91,7 +90,6 @@ describe('reassignAgents (plural)', () => { // calls ES update with correct values const calledWith = esClient.bulk.mock.calls[0][0]; // only 1 are unmanaged and bulk write two line per update - // @ts-expect-error expect(calledWith.body.length).toBe(2); // @ts-expect-error expect(calledWith.body[0].update._id).toEqual(agentInUnmanagedDoc._id); @@ -150,8 +148,8 @@ function createClientsMock() { throw new Error(`${id} not found`); } }); - // @ts-expect-error esClientMock.bulk.mockResolvedValue({ + // @ts-expect-error not full interface body: { items: [] }, }); diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index 74e60c42b9973..81b00663d7a8a 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -8,13 +8,20 @@ import type { SavedObjectsClientContract, ElasticsearchClient } from 'kibana/server'; import Boom from '@hapi/boom'; -import type { Agent } from '../../types'; +import type { Agent, BulkActionResult } from '../../types'; import { agentPolicyService } from '../agent_policy'; import { AgentReassignmentError } from '../../errors'; -import { getAgents, getAgentPolicyForAgent, updateAgent, bulkUpdateAgents } from './crud'; +import { + getAgentDocuments, + getAgents, + getAgentPolicyForAgent, + updateAgent, + bulkUpdateAgents, +} from './crud'; import type { GetAgentsOptions } from './index'; import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { searchHitToAgent } from './helpers'; export async function reassignAgent( soClient: SavedObjectsClientContract, @@ -37,7 +44,7 @@ export async function reassignAgent( await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: new Date().toISOString(), - type: 'INTERNAL_POLICY_REASSIGN', + type: 'POLICY_REASSIGN', }); } @@ -67,39 +74,67 @@ export async function reassignAgentIsAllowed( export async function reassignAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: { agents: Agent[] } | GetAgentsOptions, + options: ({ agents: Agent[] } | GetAgentsOptions) & { force?: boolean }, newAgentPolicyId: string -): Promise<{ items: Array<{ id: string; success: boolean; error?: Error }> }> { +): Promise<{ items: BulkActionResult[] }> { const agentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); if (!agentPolicy) { throw Boom.notFound(`Agent policy not found: ${newAgentPolicyId}`); } - const allResults = 'agents' in options ? options.agents : await getAgents(esClient, options); + const outgoingErrors: Record = {}; + let givenAgents: Agent[] = []; + if ('agents' in options) { + givenAgents = options.agents; + } else if ('agentIds' in options) { + const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds); + for (const agentResult of givenAgentsResults) { + if (agentResult.found === false) { + outgoingErrors[agentResult._id] = new AgentReassignmentError( + `Cannot find agent ${agentResult._id}` + ); + } else { + givenAgents.push(searchHitToAgent(agentResult)); + } + } + } else if ('kuery' in options) { + givenAgents = await getAgents(esClient, options); + } + const givenOrder = + 'agentIds' in options ? options.agentIds : givenAgents.map((agent) => agent.id); + // which are allowed to unenroll - const settled = await Promise.allSettled( - allResults.map((agent) => - reassignAgentIsAllowed(soClient, esClient, agent.id, newAgentPolicyId).then((_) => agent) - ) + const agentResults = await Promise.allSettled( + givenAgents.map(async (agent, index) => { + if (agent.policy_id === newAgentPolicyId) { + throw new AgentReassignmentError(`${agent.id} is already assigned to ${newAgentPolicyId}`); + } + + const isAllowed = await reassignAgentIsAllowed( + soClient, + esClient, + agent.id, + newAgentPolicyId + ); + if (isAllowed) { + return agent; + } + throw new AgentReassignmentError(`${agent.id} may not be reassigned to ${newAgentPolicyId}`); + }) ); // Filter to agents that do not already use the new agent policy ID - const agentsToUpdate = allResults.filter((agent, index) => { - if (settled[index].status === 'fulfilled') { - if (agent.policy_id === newAgentPolicyId) { - settled[index] = { - status: 'rejected', - reason: new AgentReassignmentError( - `${agent.id} is already assigned to ${newAgentPolicyId}` - ), - }; - } else { - return true; - } + const agentsToUpdate = agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; } - }); + return agents; + }, []); - const res = await bulkUpdateAgents( + await bulkUpdateAgents( esClient, agentsToUpdate.map((agent) => ({ agentId: agent.id, @@ -110,6 +145,18 @@ export async function reassignAgents( })) ); + const orderedOut = givenOrder.map((agentId) => { + const hasError = agentId in outgoingErrors; + const result: BulkActionResult = { + id: agentId, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agentId]; + } + return result; + }); + const now = new Date().toISOString(); await bulkCreateAgentActions( soClient, @@ -117,9 +164,9 @@ export async function reassignAgents( agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, - type: 'INTERNAL_POLICY_REASSIGN', + type: 'POLICY_REASSIGN', })) ); - return res; + return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/agents/status.test.ts b/x-pack/plugins/fleet/server/services/agents/status.test.ts index b11ea7ae7f87c..35300dfc02769 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.test.ts @@ -12,8 +12,8 @@ import { getAgentStatusById } from './status'; describe('Agent status service', () => { it('should return inactive when agent is not active', async () => { const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - // @ts-expect-error mockElasticsearchClient.get.mockResolvedValue({ + // @ts-expect-error not full interface body: { _id: 'id', _source: { @@ -29,8 +29,8 @@ describe('Agent status service', () => { it('should return online when agent is active', async () => { const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - // @ts-expect-error mockElasticsearchClient.get.mockResolvedValue({ + // @ts-expect-error not full interface body: { _id: 'id', _source: { @@ -47,8 +47,8 @@ describe('Agent status service', () => { it('should return enrolling when agent is active but never checkin', async () => { const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - // @ts-expect-error mockElasticsearchClient.get.mockResolvedValue({ + // @ts-expect-error not full interface body: { _id: 'id', _source: { @@ -64,8 +64,8 @@ describe('Agent status service', () => { it('should return unenrolling when agent is unenrolling', async () => { const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - // @ts-expect-error mockElasticsearchClient.get.mockResolvedValue({ + // @ts-expect-error not full interface body: { _id: 'id', diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index 23ba8ac7bbd7f..3d0692c242096 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -65,10 +65,8 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const calledWith = esClient.bulk.mock.calls[1][0]; const ids = calledWith?.body - // @ts-expect-error .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); - // @ts-expect-error const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); expect(ids).toHaveLength(2); expect(ids).toEqual(idsToUnenroll); @@ -90,10 +88,8 @@ describe('unenrollAgents (plural)', () => { const onlyUnmanaged = [agentInUnmanagedDoc._id, agentInUnmanagedDoc2._id]; const calledWith = esClient.bulk.mock.calls[1][0]; const ids = calledWith?.body - // @ts-expect-error .filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); - // @ts-expect-error const docs = calledWith?.body.filter((i: any) => i.doc).map((i: any) => i.doc); expect(ids).toHaveLength(onlyUnmanaged.length); expect(ids).toEqual(onlyUnmanaged); @@ -149,8 +145,8 @@ function createClientMock() { throw new Error('not found'); } }); - // @ts-expect-error esClientMock.bulk.mockResolvedValue({ + // @ts-expect-error not full interface body: { items: [] }, }); diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 8cf7396eaa8de..ff243eff11570 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -7,6 +7,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { AgentUnenrollmentError } from '../../errors'; @@ -57,26 +58,35 @@ export async function unenrollAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: GetAgentsOptions & { force?: boolean } -) { +): Promise<{ items: BulkActionResult[] }> { // start with all agents specified - const agents = await getAgents(esClient, options); + const givenAgents = await getAgents(esClient, options); + const outgoingErrors: Record = {}; // Filter to those not already unenrolled, or unenrolling - const agentsEnrolled = agents.filter((agent) => { + const agentsEnrolled = givenAgents.filter((agent) => { if (options.force) { return !agent.unenrolled_at; } return !agent.unenrollment_started_at && !agent.unenrolled_at; }); // And which are allowed to unenroll - const settled = await Promise.allSettled( + const agentResults = await Promise.allSettled( agentsEnrolled.map((agent) => unenrollAgentIsAllowed(soClient, esClient, agent.id).then((_) => agent) ) ); - const agentsToUpdate = agentsEnrolled.filter((_, index) => settled[index].status === 'fulfilled'); - const now = new Date().toISOString(); + const agentsToUpdate = agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; + } + return agents; + }, []); + const now = new Date().toISOString(); if (options.force) { // Get all API keys that need to be invalidated const apiKeys = agentsToUpdate.reduce((keys, agent) => { @@ -94,17 +104,6 @@ export async function unenrollAgents( if (apiKeys.length) { await APIKeyService.invalidateAPIKeys(soClient, apiKeys); } - // Update the necessary agents - return bulkUpdateAgents( - esClient, - agentsToUpdate.map((agent) => ({ - agentId: agent.id, - data: { - active: false, - unenrolled_at: now, - }, - })) - ); } else { // Create unenroll action for each agent await bulkCreateAgentActions( @@ -116,18 +115,32 @@ export async function unenrollAgents( type: 'UNENROLL', })) ); - - // Update the necessary agents - return bulkUpdateAgents( - esClient, - agentsToUpdate.map((agent) => ({ - agentId: agent.id, - data: { - unenrollment_started_at: now, - }, - })) - ); } + + // Update the necessary agents + const updateData = options.force + ? { unenrolled_at: now, active: false } + : { unenrollment_started_at: now }; + + await bulkUpdateAgents( + esClient, + agentsToUpdate.map(({ id }) => ({ agentId: id, data: updateData })) + ); + + const out = { + items: givenAgents.map((agent, index) => { + const hasError = agent.id in outgoingErrors; + const result: BulkActionResult = { + id: agent.id, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agent.id]; + } + return result; + }), + }; + return out; } export async function forceUnenrollAgent( diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 6c3b404a5b6f3..14b8dfaed4d91 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -7,16 +7,23 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; -import type { AgentAction, AgentActionSOAttributes } from '../../types'; +import type { Agent, AgentAction, AgentActionSOAttributes, BulkActionResult } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; import { agentPolicyService } from '../../services'; -import { IngestManagerError } from '../../errors'; +import { AgentReassignmentError, IngestManagerError } from '../../errors'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; import { bulkCreateAgentActions, createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; -import { getAgents, updateAgent, bulkUpdateAgents, getAgentPolicyForAgent } from './crud'; +import { + getAgentDocuments, + getAgents, + updateAgent, + bulkUpdateAgents, + getAgentPolicyForAgent, +} from './crud'; +import { searchHitToAgent } from './helpers'; export async function sendUpgradeAgentAction({ soClient, @@ -77,39 +84,75 @@ export async function ackAgentUpgraded( export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: GetAgentsOptions & { + options: ({ agents: Agent[] } | GetAgentsOptions) & { sourceUri: string | undefined; version: string; force?: boolean; } ) { // Full set of agents - const agentsGiven = await getAgents(esClient, options); + const outgoingErrors: Record = {}; + let givenAgents: Agent[] = []; + if ('agents' in options) { + givenAgents = options.agents; + } else if ('agentIds' in options) { + const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds); + for (const agentResult of givenAgentsResults) { + if (agentResult.found === false) { + outgoingErrors[agentResult._id] = new AgentReassignmentError( + `Cannot find agent ${agentResult._id}` + ); + } else { + givenAgents.push(searchHitToAgent(agentResult)); + } + } + } else if ('kuery' in options) { + givenAgents = await getAgents(esClient, options); + } + const givenOrder = + 'agentIds' in options ? options.agentIds : givenAgents.map((agent) => agent.id); + + // get any policy ids from upgradable agents + const policyIdsToGet = new Set( + givenAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) + ); + + // get the agent policies for those ids + const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { + fields: ['is_managed'], + }); + const managedPolicies = agentPolicies.reduce>((acc, policy) => { + acc[policy.id] = policy.is_managed; + return acc; + }, {}); // Filter out agents currently unenrolling, unenrolled, or not upgradeable b/c of version check const kibanaVersion = appContextService.getKibanaVersion(); - const upgradeableAgents = options.force - ? agentsGiven - : agentsGiven.filter((agent) => isAgentUpgradeable(agent, kibanaVersion)); - - if (!options.force) { - // get any policy ids from upgradable agents - const policyIdsToGet = new Set( - upgradeableAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) - ); - - // get the agent policies for those ids - const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { - fields: ['is_managed'], - }); + const agentResults = await Promise.allSettled( + givenAgents.map(async (agent) => { + const isAllowed = options.force || isAgentUpgradeable(agent, kibanaVersion); + if (!isAllowed) { + throw new IngestManagerError(`${agent.id} is not upgradeable`); + } - // throw if any of those agent policies are managed - for (const policy of agentPolicies) { - if (policy.is_managed) { - throw new IngestManagerError(`Cannot upgrade agent in managed policy ${policy.id}`); + if (!options.force && agent.policy_id && managedPolicies[agent.policy_id]) { + throw new IngestManagerError(`Cannot upgrade agent in managed policy ${agent.policy_id}`); } + return agent; + }) + ); + + // Filter to agents that do not already use the new agent policy ID + const agentsToUpdate = agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; } - } + return agents; + }, []); + // Create upgrade action for each agent const now = new Date().toISOString(); const data = { @@ -120,7 +163,7 @@ export async function sendUpgradeAgentsActions( await bulkCreateAgentActions( soClient, esClient, - upgradeableAgents.map((agent) => ({ + agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, data, @@ -129,9 +172,9 @@ export async function sendUpgradeAgentsActions( })) ); - return await bulkUpdateAgents( + await bulkUpdateAgents( esClient, - upgradeableAgents.map((agent) => ({ + agentsToUpdate.map((agent) => ({ agentId: agent.id, data: { upgraded_at: null, @@ -139,4 +182,17 @@ export async function sendUpgradeAgentsActions( }, })) ); + const orderedOut = givenOrder.map((agentId) => { + const hasError = agentId in outgoingErrors; + const result: BulkActionResult = { + id: agentId, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agentId]; + } + return result; + }); + + return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 4365c3913f433..b3edb20d51c4f 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -8,7 +8,6 @@ import uuid from 'uuid'; import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; -import type { GetResponse } from 'elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; @@ -42,10 +41,12 @@ export async function listEnrollmentApiKeys( q: kuery, }); + // @ts-expect-error @elastic/elasticsearch const items = res.body.hits.hits.map(esDocToEnrollmentApiKey); return { items, + // @ts-expect-error value is number | TotalHits total: res.body.hits.total.value, page, perPage, @@ -57,11 +58,12 @@ export async function getEnrollmentAPIKey( id: string ): Promise { try { - const res = await esClient.get>({ + const res = await esClient.get({ index: ENROLLMENT_API_KEYS_INDEX, id, }); + // @ts-expect-error esDocToEnrollmentApiKey doesn't accept optional _source return esDocToEnrollmentApiKey(res.body); } catch (e) { if (e instanceof ResponseError && e.statusCode === 404) { @@ -226,11 +228,12 @@ export async function generateEnrollmentAPIKey( } export async function getEnrollmentAPIKeyById(esClient: ElasticsearchClient, apiKeyId: string) { - const res = await esClient.search>({ + const res = await esClient.search({ index: ENROLLMENT_API_KEYS_INDEX, q: `api_key_id:${escapeSearchQueryPhrase(apiKeyId)}`, }); + // @ts-expect-error esDocToEnrollmentApiKey doesn't accept optional _source const [enrollmentAPIKey] = res.body.hits.hits.map(esDocToEnrollmentApiKey); if (enrollmentAPIKey?.api_key_id !== apiKeyId) { diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts index 9a12f6a3c0bdf..77785aeb026c1 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts @@ -16,7 +16,6 @@ import type { ElasticsearchClient } from 'kibana/server'; import type { ListResult } from '../../../common'; import { FLEET_SERVER_ARTIFACTS_INDEX } from '../../../common'; -import type { ESSearchHit, ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { ArtifactsElasticsearchError } from '../../errors'; @@ -38,11 +37,12 @@ export const getArtifact = async ( id: string ): Promise => { try { - const esData = await esClient.get>({ + const esData = await esClient.get({ index: FLEET_SERVER_ARTIFACTS_INDEX, id, }); + // @ts-expect-error @elastic/elasticsearch _source is optional return esSearchHitToArtifact(esData.body); } catch (e) { if (isElasticsearchItemNotFoundError(e)) { @@ -92,9 +92,7 @@ export const listArtifacts = async ( const { perPage = 20, page = 1, kuery = '', sortField = 'created', sortOrder = 'asc' } = options; try { - const searchResult = await esClient.search< - ESSearchResponse - >({ + const searchResult = await esClient.search({ index: FLEET_SERVER_ARTIFACTS_INDEX, sort: `${sortField}:${sortOrder}`, q: kuery, @@ -103,9 +101,11 @@ export const listArtifacts = async ( }); return { + // @ts-expect-error @elastic/elasticsearch _source is optional items: searchResult.body.hits.hits.map((hit) => esSearchHitToArtifact(hit)), page, perPage, + // @ts-expect-error doesn't handle total as number total: searchResult.body.hits.total.value, }; } catch (e) { diff --git a/x-pack/plugins/fleet/server/services/artifacts/mappings.ts b/x-pack/plugins/fleet/server/services/artifacts/mappings.ts index 43aa111f2efcf..3b81e47577ff7 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/mappings.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/mappings.ts @@ -5,13 +5,13 @@ * 2.0. */ -import type { ESSearchHit } from '../../../../../../typings/elasticsearch'; +import type { SearchHit } from '../../../../../../typings/elasticsearch'; import type { Artifact, ArtifactElasticsearchProperties, NewArtifact } from './types'; import { ARTIFACT_DOWNLOAD_RELATIVE_PATH } from './constants'; export const esSearchHitToArtifact = < - T extends Pick, '_id' | '_source'> + T extends Pick, '_id' | '_source'> >({ _id: id, _source: { diff --git a/x-pack/plugins/fleet/server/services/artifacts/mocks.ts b/x-pack/plugins/fleet/server/services/artifacts/mocks.ts index 5569e4ac77d20..1a10f93f678b3 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/mocks.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/mocks.ts @@ -10,7 +10,7 @@ import type { ApiResponse } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; -import type { ESSearchHit, ESSearchResponse } from '../../../../../../typings/elasticsearch'; +import type { SearchHit, ESSearchResponse } from '../../../../../../typings/elasticsearch'; import type { Artifact, ArtifactElasticsearchProperties, ArtifactsClientInterface } from './types'; import { newArtifactToElasticsearchProperties } from './mappings'; @@ -77,7 +77,7 @@ export const generateEsRequestErrorApiResponseMock = ( ); }; -export const generateArtifactEsGetSingleHitMock = (): ESSearchHit => { +export const generateArtifactEsGetSingleHitMock = (): SearchHit => { const { id, created, ...newArtifact } = generateArtifactMock(); const _source = { ...newArtifactToElasticsearchProperties(newArtifact), 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 456ed95a8c3e4..0b95f8d76627a 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 @@ -497,6 +497,7 @@ const updateExistingDataStream = async ({ await esClient.indices.putMapping({ index: dataStreamName, body: mappings, + // @ts-expect-error @elastic/elasticsearch doesn't declare it on PutMappingRequest write_index_only: true, }); // if update fails, rollover data stream diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index cbd09b8d1e7a8..7d62c0ef41c8d 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -123,6 +123,7 @@ async function handleTransformInstall({ await esClient.transform.putTransform({ transform_id: transform.installationName, defer_validation: true, + // @ts-expect-error expect object, but given a string body: transform.content, }); } catch (err) { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/remove.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/remove.ts index 248c03e43add9..39681401ac955 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/remove.ts @@ -55,6 +55,7 @@ export const deleteTransforms = async (esClient: ElasticsearchClient, transformI await esClient.transport.request( { method: 'DELETE', + // @ts-expect-error @elastic/elasticsearch Transform is empty interface path: `/${transform?.dest?.index}`, }, { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 21e4e31be2bd0..de798e822b029 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -32,13 +32,14 @@ export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; pkgkey: string; esClient: ElasticsearchClient; + force?: boolean; }): Promise { - const { savedObjectsClient, pkgkey, esClient } = options; + const { savedObjectsClient, pkgkey, esClient, force } = options; // TODO: the epm api should change to /name/version so we don't need to do this const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); - if (installation.removable === false) + if (installation.removable === false && !force) throw Boom.badRequest(`${pkgName} is installed by default and cannot be removed`); const { total } = await packagePolicyService.list(savedObjectsClient, { diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json index 9937e9ad66e56..58ae1a2e00ea4 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json @@ -187,6 +187,9 @@ "policy_id": { "type": "keyword" }, + "policy_output_permissions_hash": { + "type": "keyword" + }, "policy_revision_idx": { "type": "integer" }, diff --git a/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts index 1d5a788c3a2c2..f078b214e4dfd 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts @@ -169,6 +169,7 @@ async function migrateAgentPolicies() { track_total_hits: true, }); + // @ts-expect-error value is number | TotalHits if (res.body.hits.total.value === 0) { return agentPolicyService.createFleetPolicyChangeFleetServer( soClient, diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 03348a2fcc4bb..7658a8d71839e 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -27,6 +27,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise return { id: settingsSo.id, ...settingsSo.attributes, + fleet_server_hosts: settingsSo.attributes.fleet_server_hosts || [], }; } @@ -81,7 +82,10 @@ export function createDefaultSettings(): BaseSettings { pathname: basePath.serverBasePath, }); + const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? []; + return { kibana_urls: [cloudUrl || flagsUrl || defaultUrl].flat(), + fleet_server_hosts: fleetServerHosts, }; } diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index c25b047c0e1ad..2b46f7e76a719 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -90,5 +90,11 @@ export type AgentPolicyUpdateHandler = ( agentPolicyId: string ) => Promise; +export interface BulkActionResult { + id: string; + success: boolean; + error?: Error; +} + export * from './models'; export * from './rest_spec'; diff --git a/x-pack/plugins/fleet/server/types/models/agent.ts b/x-pack/plugins/fleet/server/types/models/agent.ts index b0b28fdb5b2c8..192bb83a88718 100644 --- a/x-pack/plugins/fleet/server/types/models/agent.ts +++ b/x-pack/plugins/fleet/server/types/models/agent.ts @@ -70,7 +70,7 @@ export const NewAgentActionSchema = schema.oneOf([ schema.literal('POLICY_CHANGE'), schema.literal('UNENROLL'), schema.literal('UPGRADE'), - schema.literal('INTERNAL_POLICY_REASSIGN'), + schema.literal('POLICY_REASSIGN'), ]), data: schema.maybe(schema.any()), ack_data: schema.maybe(schema.any()), diff --git a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts index 2243a7d3930cd..f7e3ed906e24b 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -65,4 +65,9 @@ export const DeletePackageRequestSchema = { params: schema.object({ pkgkey: schema.string(), }), + body: schema.nullable( + schema.object({ + force: schema.boolean(), + }) + ), }; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts index 9bbebbe86ccaa..9051d7a06efff 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -13,6 +13,15 @@ export const GetSettingsRequestSchema = {}; export const PutSettingsRequestSchema = { body: schema.object({ + fleet_server_hosts: schema.maybe( + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + validate: (value) => { + if (value.length && isDiffPathProtocol(value)) { + return 'Protocol and path must be the same for each URL'; + } + }, + }) + ), kibana_urls: schema.maybe( schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { validate: (value) => { diff --git a/x-pack/plugins/global_search_bar/jest.config.js b/x-pack/plugins/global_search_bar/jest.config.js index 73cf5402a83a9..26a6934226ec4 100644 --- a/x-pack/plugins/global_search_bar/jest.config.js +++ b/x-pack/plugins/global_search_bar/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/global_search_bar'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95200 + testEnvironment: 'jest-environment-jsdom-thirteen', }; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts index a18459d5d21b9..77f14decc5642 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_add_policy_route.ts @@ -24,6 +24,7 @@ async function addLifecyclePolicy( }, }; + // @ts-expect-error @elastic/elasticsearch UpdateIndexSettingsRequest does not support index property return client.indices.putSettings({ index: indexName, body }); } diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts index 72768a1540adc..069adc139a86d 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts @@ -17,6 +17,7 @@ async function deletePolicies(client: ElasticsearchClient, policyName: string): ignore: [404], }; + // @ts-expect-error @elastic/elasticsearch DeleteSnapshotLifecycleRequest.policy_id is required return client.ilm.deleteLifecycle({ policy: policyName }, options); } diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts index 75320b6f2d242..9aa5e3c24d010 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts @@ -33,6 +33,7 @@ async function fetchPolicies(client: ElasticsearchClient): Promise( + const response = await client.indices.getIndexTemplate( { name: templateName, }, options ); - const { index_templates: templates } = response.body; - return templates?.find((template) => template.name === templateName)?.index_template; + const { index_templates: templates } = response.body as { + index_templates: TemplateFromEs[]; + }; + return templates.find((template) => template.name === templateName)?.index_template; } async function updateIndexTemplate( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx index 6b3982cb50c59..b2647b175b324 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; -import { uiSettingsServiceMock } from '../../../../../../../../../../src/core/public/mocks'; +import { + docLinksServiceMock, + uiSettingsServiceMock, +} from '../../../../../../../../../../src/core/public/mocks'; import { MappingsEditorProvider } from '../../../mappings_editor_context'; import { createKibanaReactContext } from '../../../shared_imports'; @@ -80,10 +83,7 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ }); const defaultProps = { - docLinks: { - DOC_LINK_VERSION: 'master', - ELASTIC_WEBSITE_URL: 'https://jest.elastic.co', - }, + docLinks: docLinksServiceMock.createStartContract(), }; export const WithAppDependencies = (Comp: any) => (props: any) => ( diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index 77917db95a116..9573b9cc6436f 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -91,7 +91,7 @@ const getDataStreamsStats = (client: ElasticsearchClient, name = '*') => { }; const getDataStreamsPrivileges = (client: ElasticsearchClient, names: string[]) => { - return client.security.hasPrivileges({ + return client.security.hasPrivileges({ body: { index: [ { @@ -143,6 +143,7 @@ export function registerGetAllRoute({ dataStreams = enhanceDataStreams({ dataStreams, dataStreamsStats, + // @ts-expect-error PrivilegesFromEs incompatible with ApplicationsPrivileges dataStreamsPrivileges, }); @@ -195,6 +196,7 @@ export function registerGetOneRoute({ const enhancedDataStreams = enhanceDataStreams({ dataStreams, dataStreamsStats, + // @ts-expect-error PrivilegesFromEs incompatible with ApplicationsPrivileges dataStreamsPrivileges, }); const body = deserializeDataStream(enhancedDataStreams[0]); diff --git a/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts new file mode 100644 index 0000000000000..ad4b2963a41bd --- /dev/null +++ b/x-pack/plugins/infra/common/log_sources/resolve_log_source_configuration.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogSourceConfigurationProperties } from '../http_api/log_sources'; + +// NOTE: Type will change, see below. +type ResolvedLogsSourceConfiguration = LogSourceConfigurationProperties; + +// NOTE: This will handle real resolution for https://github.com/elastic/kibana/issues/92650, via the index patterns service, but for now just +// hands back properties from the saved object (and therefore looks pointless...). +export const resolveLogSourceConfiguration = ( + sourceConfiguration: LogSourceConfigurationProperties +): ResolvedLogsSourceConfiguration => { + return sourceConfiguration; +}; diff --git a/x-pack/plugins/infra/common/metrics_sources/index.ts b/x-pack/plugins/infra/common/metrics_sources/index.ts new file mode 100644 index 0000000000000..a697c65e5a0aa --- /dev/null +++ b/x-pack/plugins/infra/common/metrics_sources/index.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { omit } from 'lodash'; +import { + SourceConfigurationRT, + SourceStatusRuntimeType, +} from '../source_configuration/source_configuration'; +import { DeepPartial } from '../utility_types'; + +/** + * Properties specific to the Metrics Source Configuration. + */ +export const metricsSourceConfigurationPropertiesRT = rt.strict({ + name: SourceConfigurationRT.props.name, + description: SourceConfigurationRT.props.description, + metricAlias: SourceConfigurationRT.props.metricAlias, + inventoryDefaultView: SourceConfigurationRT.props.inventoryDefaultView, + metricsExplorerDefaultView: SourceConfigurationRT.props.metricsExplorerDefaultView, + fields: rt.strict(omit(SourceConfigurationRT.props.fields.props, 'message')), + anomalyThreshold: rt.number, +}); + +export type MetricsSourceConfigurationProperties = rt.TypeOf< + typeof metricsSourceConfigurationPropertiesRT +>; + +export const partialMetricsSourceConfigurationPropertiesRT = rt.partial({ + ...metricsSourceConfigurationPropertiesRT.type.props, + fields: rt.partial({ + ...metricsSourceConfigurationPropertiesRT.type.props.fields.type.props, + }), +}); + +export type PartialMetricsSourceConfigurationProperties = rt.TypeOf< + typeof partialMetricsSourceConfigurationPropertiesRT +>; + +const metricsSourceConfigurationOriginRT = rt.keyof({ + fallback: null, + internal: null, + stored: null, +}); + +export const metricsSourceStatusRT = rt.strict({ + metricIndicesExist: SourceStatusRuntimeType.props.metricIndicesExist, + indexFields: SourceStatusRuntimeType.props.indexFields, +}); + +export type MetricsSourceStatus = rt.TypeOf; + +export const metricsSourceConfigurationRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + origin: metricsSourceConfigurationOriginRT, + configuration: metricsSourceConfigurationPropertiesRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + status: metricsSourceStatusRT, + }), + ]) +); + +export type MetricsSourceConfiguration = rt.TypeOf; +export type PartialMetricsSourceConfiguration = DeepPartial; + +export const metricsSourceConfigurationResponseRT = rt.type({ + source: metricsSourceConfigurationRT, +}); + +export type MetricsSourceConfigurationResponse = rt.TypeOf< + typeof metricsSourceConfigurationResponseRT +>; diff --git a/x-pack/plugins/infra/common/http_api/source_api.ts b/x-pack/plugins/infra/common/source_configuration/source_configuration.ts similarity index 61% rename from x-pack/plugins/infra/common/http_api/source_api.ts rename to x-pack/plugins/infra/common/source_configuration/source_configuration.ts index f14151531ba35..ad68a7a019848 100644 --- a/x-pack/plugins/infra/common/http_api/source_api.ts +++ b/x-pack/plugins/infra/common/source_configuration/source_configuration.ts @@ -5,8 +5,19 @@ * 2.0. */ +/** + * These are the core source configuration types that represent a Source Configuration in + * it's entirety. There are then subsets of this configuration that form the Logs Source Configuration + * and Metrics Source Configuration. The Logs Source Configuration is further expanded to it's resolved form. + * -> Source Configuration + * -> Logs source configuration + * -> Resolved Logs Source Configuration + * -> Metrics Source Configuration + */ + /* eslint-disable @typescript-eslint/no-empty-interface */ +import { omit } from 'lodash'; import * as rt from 'io-ts'; import moment from 'moment'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -29,121 +40,113 @@ export const TimestampFromString = new rt.Type( ); /** - * Stored source configuration as read from and written to saved objects + * Log columns */ -const SavedSourceConfigurationFieldsRuntimeType = rt.partial({ - container: rt.string, - host: rt.string, - pod: rt.string, - tiebreaker: rt.string, - timestamp: rt.string, -}); - -export type InfraSavedSourceConfigurationFields = rt.TypeOf< - typeof SavedSourceConfigurationFieldColumnRuntimeType ->; - -export const SavedSourceConfigurationTimestampColumnRuntimeType = rt.type({ +export const SourceConfigurationTimestampColumnRuntimeType = rt.type({ timestampColumn: rt.type({ id: rt.string, }), }); export type InfraSourceConfigurationTimestampColumn = rt.TypeOf< - typeof SavedSourceConfigurationTimestampColumnRuntimeType + typeof SourceConfigurationTimestampColumnRuntimeType >; -export const SavedSourceConfigurationMessageColumnRuntimeType = rt.type({ +export const SourceConfigurationMessageColumnRuntimeType = rt.type({ messageColumn: rt.type({ id: rt.string, }), }); export type InfraSourceConfigurationMessageColumn = rt.TypeOf< - typeof SavedSourceConfigurationMessageColumnRuntimeType + typeof SourceConfigurationMessageColumnRuntimeType >; -export const SavedSourceConfigurationFieldColumnRuntimeType = rt.type({ +export const SourceConfigurationFieldColumnRuntimeType = rt.type({ fieldColumn: rt.type({ id: rt.string, field: rt.string, }), }); -export const SavedSourceConfigurationColumnRuntimeType = rt.union([ - SavedSourceConfigurationTimestampColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationFieldColumnRuntimeType, +export type InfraSourceConfigurationFieldColumn = rt.TypeOf< + typeof SourceConfigurationFieldColumnRuntimeType +>; + +export const SourceConfigurationColumnRuntimeType = rt.union([ + SourceConfigurationTimestampColumnRuntimeType, + SourceConfigurationMessageColumnRuntimeType, + SourceConfigurationFieldColumnRuntimeType, ]); -export type InfraSavedSourceConfigurationColumn = rt.TypeOf< - typeof SavedSourceConfigurationColumnRuntimeType ->; +export type InfraSourceConfigurationColumn = rt.TypeOf; -export const SavedSourceConfigurationRuntimeType = rt.partial({ +/** + * Fields + */ + +const SourceConfigurationFieldsRT = rt.type({ + container: rt.string, + host: rt.string, + pod: rt.string, + tiebreaker: rt.string, + timestamp: rt.string, + message: rt.array(rt.string), +}); + +/** + * Properties that represent a full source configuration, which is the result of merging static values with + * saved values. + */ +export const SourceConfigurationRT = rt.type({ name: rt.string, description: rt.string, metricAlias: rt.string, logAlias: rt.string, inventoryDefaultView: rt.string, metricsExplorerDefaultView: rt.string, - fields: SavedSourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), + fields: SourceConfigurationFieldsRT, + logColumns: rt.array(SourceConfigurationColumnRuntimeType), anomalyThreshold: rt.number, }); +/** + * Stored source configuration as read from and written to saved objects + */ +const SavedSourceConfigurationFieldsRuntimeType = rt.partial( + omit(SourceConfigurationFieldsRT.props, ['message']) +); + +export type InfraSavedSourceConfigurationFields = rt.TypeOf< + typeof SavedSourceConfigurationFieldsRuntimeType +>; + +export const SavedSourceConfigurationRuntimeType = rt.intersection([ + rt.partial(omit(SourceConfigurationRT.props, ['fields'])), + rt.partial({ + fields: SavedSourceConfigurationFieldsRuntimeType, + }), +]); + export interface InfraSavedSourceConfiguration extends rt.TypeOf {} export const pickSavedSourceConfiguration = ( value: InfraSourceConfiguration ): InfraSavedSourceConfiguration => { - const { - name, - description, - metricAlias, - logAlias, - fields, - inventoryDefaultView, - metricsExplorerDefaultView, - logColumns, - anomalyThreshold, - } = value; - const { container, host, pod, tiebreaker, timestamp } = fields; - - return { - name, - description, - metricAlias, - logAlias, - inventoryDefaultView, - metricsExplorerDefaultView, - fields: { container, host, pod, tiebreaker, timestamp }, - logColumns, - anomalyThreshold, - }; + return value; }; /** - * Static source configuration as read from the configuration file + * Static source configuration, the result of merging values from the config file and + * hardcoded defaults. */ -const StaticSourceConfigurationFieldsRuntimeType = rt.partial({ - ...SavedSourceConfigurationFieldsRuntimeType.props, - message: rt.array(rt.string), -}); - +const StaticSourceConfigurationFieldsRuntimeType = rt.partial(SourceConfigurationFieldsRT.props); export const StaticSourceConfigurationRuntimeType = rt.partial({ - name: rt.string, - description: rt.string, - metricAlias: rt.string, - logAlias: rt.string, - inventoryDefaultView: rt.string, - metricsExplorerDefaultView: rt.string, + ...SourceConfigurationRT.props, fields: StaticSourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), - anomalyThreshold: rt.number, }); export interface InfraStaticSourceConfiguration @@ -153,18 +156,20 @@ export interface InfraStaticSourceConfiguration * Full source configuration type after all cleanup has been done at the edges */ -const SourceConfigurationFieldsRuntimeType = rt.type({ - ...StaticSourceConfigurationFieldsRuntimeType.props, -}); - -export type InfraSourceConfigurationFields = rt.TypeOf; +export type InfraSourceConfigurationFields = rt.TypeOf; export const SourceConfigurationRuntimeType = rt.type({ - ...SavedSourceConfigurationRuntimeType.props, - fields: SourceConfigurationFieldsRuntimeType, - logColumns: rt.array(SavedSourceConfigurationColumnRuntimeType), + ...SourceConfigurationRT.props, + fields: SourceConfigurationFieldsRT, + logColumns: rt.array(SourceConfigurationColumnRuntimeType), }); +export interface InfraSourceConfiguration + extends rt.TypeOf {} + +/** + * Source status + */ const SourceStatusFieldRuntimeType = rt.type({ name: rt.string, type: rt.string, @@ -175,12 +180,17 @@ const SourceStatusFieldRuntimeType = rt.type({ export type InfraSourceIndexField = rt.TypeOf; -const SourceStatusRuntimeType = rt.type({ +export const SourceStatusRuntimeType = rt.type({ logIndicesExist: rt.boolean, metricIndicesExist: rt.boolean, indexFields: rt.array(SourceStatusFieldRuntimeType), }); +export interface InfraSourceStatus extends rt.TypeOf {} + +/** + * Source configuration along with source status and metadata + */ export const SourceRuntimeType = rt.intersection([ rt.type({ id: rt.string, @@ -198,11 +208,6 @@ export const SourceRuntimeType = rt.intersection([ }), ]); -export interface InfraSourceStatus extends rt.TypeOf {} - -export interface InfraSourceConfiguration - extends rt.TypeOf {} - export interface InfraSource extends rt.TypeOf {} export const SourceResponseRuntimeType = rt.type({ diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 891e98606264e..b345e138accec 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { mountWithIntl, shallowWithIntl, nextTick } from '@kbn/test/jest'; // We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` import { coreMock as mockCoreMock } from 'src/core/public/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -17,7 +17,7 @@ import { act } from 'react-dom/test-utils'; import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; import { SnapshotCustomMetricInput } from '../../../../common/http_api/snapshot_api'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), @@ -89,6 +89,44 @@ describe('Expression', () => { }, ]); }); + + it('should pass the elasticsearch query to the expression chart', async () => { + const FILTER_QUERY = + '{"bool":{"should":[{"match_phrase":{"host.name":"testHostName"}}],"minimum_should_match":1}}'; + + const alertParams = { + criteria: [ + { + metric: 'cpu', + timeSize: 1, + timeUnit: 'm', + threshold: [10], + comparator: Comparator.GT, + }, + ], + nodeType: undefined, + filterQueryText: 'host.name: "testHostName"', + filterQuery: FILTER_QUERY, + }; + + const wrapper = shallowWithIntl( + Reflect.set(alertParams, key, value)} + setAlertProperty={() => {}} + metadata={{}} + /> + ); + + const chart = wrapper.find('[data-test-subj="preview-chart"]'); + + expect(chart.prop('filterQuery')).toBe(FILTER_QUERY); + }); + describe('using custom metrics', () => { it('should prefill the alert using the context metadata', async () => { const currentOptions = { diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 9ce7162933f2d..c4f8b5a615b0f 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -43,7 +43,7 @@ import { AlertTypeParamsExpressionProps, } from '../../../../../triggers_actions_ui/public'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items'; import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; @@ -124,14 +124,13 @@ export const Expressions: React.FC = (props) => { } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); @@ -319,9 +318,10 @@ export const Expressions: React.FC = (props) => { > ); diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index dd4cbe10b74ee..6b99aff9f903d 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { Expression, AlertContextMeta } from './expression'; import { act } from 'react-dom/test-utils'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 12cc2bf9fb3a9..afbd6ffa8b5f7 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -27,7 +27,7 @@ import { AlertTypeParamsExpressionProps, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/types'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { NodeTypeExpression } from './node_type'; @@ -75,12 +75,11 @@ export const Expression: React.FC = (props) => { } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx index a6d74d4f461a6..667f5c061ce48 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -15,7 +15,7 @@ import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 3b8afc173c2bd..8835a7cd55ce8 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -35,7 +35,7 @@ import { import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../containers/metrics_source/use_source_via_http'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; import { ExpressionRow } from './expression_row'; @@ -73,14 +73,13 @@ export const Expressions: React.FC = (props) => { const { http, notifications } = useKibanaContextForPlugin().services; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', fetch: http.fetch, toastWarning: notifications.toasts.addWarning, }); const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index 7e4209e4253d7..caf8e32814fe5 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -10,7 +10,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { coreMock as mockCoreMock } from 'src/core/public/mocks'; import { MetricExpression } from '../types'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import React from 'react'; import { ExpressionChart } from './expression_chart'; import { act } from 'react-dom/test-utils'; @@ -45,20 +45,17 @@ describe('ExpressionChart', () => { fields: [], }; - const source: InfraSource = { + const source: MetricsSourceConfiguration = { id: 'default', origin: 'fallback', configuration: { name: 'default', description: 'The default configuration', - logColumns: [], metricAlias: 'metricbeat-*', - logAlias: 'filebeat-*', inventoryDefaultView: 'host', metricsExplorerDefaultView: 'host', fields: { timestamp: '@timestamp', - message: ['message'], container: 'container.id', host: 'host.name', pod: 'kubernetes.pod.uid', diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index 2a274c4b6d50f..e5558b961ab20 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -11,7 +11,7 @@ import { first, last } from 'lodash'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { Color } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; @@ -35,7 +35,7 @@ import { ThresholdAnnotations } from '../../common/criterion_preview_chart/thres interface Props { expression: MetricExpression; derivedIndexPattern: IIndexPattern; - source: InfraSource | null; + source: MetricsSourceConfiguration | null; filterQuery?: string; groupBy?: string | string[]; } diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx index 54477a39c2626..90f75e6a94022 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.test.tsx @@ -13,7 +13,7 @@ import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; -jest.mock('../../../containers/source/use_source_via_http', () => ({ +jest.mock('../../../containers/metrics_source/use_source_via_http', () => ({ useSourceViaHttp: () => ({ source: { id: 'default' }, createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts index 908372d13b6bc..e3006993216ae 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts @@ -7,7 +7,7 @@ import { IIndexPattern } from 'src/plugins/data/public'; import { useMemo } from 'react'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { MetricExpression } from '../types'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data'; @@ -15,7 +15,7 @@ import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/ export const useMetricsExplorerChartData = ( expression: MetricExpression, derivedIndexPattern: IIndexPattern, - source: InfraSource | null, + source: MetricsSourceConfiguration | null, filterQuery?: string, groupBy?: string | string[] ) => { diff --git a/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx b/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx deleted file mode 100644 index b5b28cb25b83b..0000000000000 --- a/x-pack/plugins/infra/public/components/source_configuration/log_columns_configuration_form_state.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo, useState } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - LogColumnConfiguration, - isTimestampLogColumnConfiguration, - isMessageLogColumnConfiguration, - TimestampLogColumnConfiguration, - MessageLogColumnConfiguration, - FieldLogColumnConfiguration, -} from '../../utils/source_configuration'; - -export interface TimestampLogColumnConfigurationProps { - logColumnConfiguration: TimestampLogColumnConfiguration['timestampColumn']; - remove: () => void; - type: 'timestamp'; -} - -export interface MessageLogColumnConfigurationProps { - logColumnConfiguration: MessageLogColumnConfiguration['messageColumn']; - remove: () => void; - type: 'message'; -} - -export interface FieldLogColumnConfigurationProps { - logColumnConfiguration: FieldLogColumnConfiguration['fieldColumn']; - remove: () => void; - type: 'field'; -} - -export type LogColumnConfigurationProps = - | TimestampLogColumnConfigurationProps - | MessageLogColumnConfigurationProps - | FieldLogColumnConfigurationProps; - -interface FormState { - logColumns: LogColumnConfiguration[]; -} - -type FormStateChanges = Partial; - -export const useLogColumnsConfigurationFormState = ({ - initialFormState = defaultFormState, -}: { - initialFormState?: FormState; -}) => { - const [formStateChanges, setFormStateChanges] = useState({}); - - const resetForm = useCallback(() => setFormStateChanges({}), []); - - const formState = useMemo( - () => ({ - ...initialFormState, - ...formStateChanges, - }), - [initialFormState, formStateChanges] - ); - - const logColumnConfigurationProps = useMemo( - () => - formState.logColumns.map( - (logColumn): LogColumnConfigurationProps => { - const remove = () => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: formState.logColumns.filter((item) => item !== logColumn), - })); - - if (isTimestampLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.timestampColumn, - remove, - type: 'timestamp', - }; - } else if (isMessageLogColumnConfiguration(logColumn)) { - return { - logColumnConfiguration: logColumn.messageColumn, - remove, - type: 'message', - }; - } else { - return { - logColumnConfiguration: logColumn.fieldColumn, - remove, - type: 'field', - }; - } - } - ), - [formState.logColumns] - ); - - const addLogColumn = useCallback( - (logColumnConfiguration: LogColumnConfiguration) => - setFormStateChanges((changes) => ({ - ...changes, - logColumns: [...formState.logColumns, logColumnConfiguration], - })), - [formState.logColumns] - ); - - const moveLogColumn = useCallback( - (sourceIndex, destinationIndex) => { - if (destinationIndex >= 0 && sourceIndex <= formState.logColumns.length - 1) { - const newLogColumns = [...formState.logColumns]; - newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]); - setFormStateChanges((changes) => ({ - ...changes, - logColumns: newLogColumns, - })); - } - }, - [formState.logColumns] - ); - - const errors = useMemo( - () => - logColumnConfigurationProps.length <= 0 - ? [ - , - ] - : [], - [logColumnConfigurationProps] - ); - - const isFormValid = useMemo(() => (errors.length <= 0 ? true : false), [errors]); - - const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]); - - return { - addLogColumn, - moveLogColumn, - errors, - logColumnConfigurationProps, - formState, - formStateChanges, - isFormDirty, - isFormValid, - resetForm, - }; -}; - -const defaultFormState: FormState = { - logColumns: [], -}; diff --git a/x-pack/plugins/infra/public/containers/source/index.ts b/x-pack/plugins/infra/public/containers/metrics_source/index.ts similarity index 100% rename from x-pack/plugins/infra/public/containers/source/index.ts rename to x-pack/plugins/infra/public/containers/metrics_source/index.ts diff --git a/x-pack/plugins/infra/public/containers/source/source.tsx b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx similarity index 79% rename from x-pack/plugins/infra/public/containers/source/source.tsx rename to x-pack/plugins/infra/public/containers/metrics_source/source.tsx index 8e2a8f29e03df..b730f8b007e43 100644 --- a/x-pack/plugins/infra/public/containers/source/source.tsx +++ b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx @@ -9,27 +9,25 @@ import createContainer from 'constate'; import { useEffect, useMemo, useState } from 'react'; import { - InfraSavedSourceConfiguration, - InfraSource, - SourceResponse, -} from '../../../common/http_api/source_api'; + MetricsSourceConfigurationResponse, + MetricsSourceConfiguration, + PartialMetricsSourceConfigurationProperties, +} from '../../../common/metrics_sources'; + import { useTrackedPromise } from '../../utils/use_tracked_promise'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const pickIndexPattern = ( - source: InfraSource | undefined, - type: 'logs' | 'metrics' | 'both' + source: MetricsSourceConfiguration | undefined, + type: 'metrics' ) => { if (!source) { return 'unknown-index'; } - if (type === 'logs') { - return source.configuration.logAlias; - } if (type === 'metrics') { return source.configuration.metricAlias; } - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; + return `${source.configuration.metricAlias}`; }; const DEPENDENCY_ERROR_MESSAGE = 'Failed to load source: No fetch client available.'; @@ -39,7 +37,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const fetchService = kibana.services.http?.fetch; const API_URL = `/api/metrics/source/${sourceId}`; - const [source, setSource] = useState(undefined); + const [source, setSource] = useState(undefined); const [loadSourceRequest, loadSource] = useTrackedPromise( { @@ -49,7 +47,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(`${API_URL}/metrics`, { + return await fetchService(`${API_URL}`, { method: 'GET', }); }, @@ -62,12 +60,12 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const [createSourceConfigurationRequest, createSourceConfiguration] = useTrackedPromise( { - createPromise: async (sourceProperties: InfraSavedSourceConfiguration) => { + createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { if (!fetchService) { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(API_URL, { + return await fetchService(API_URL, { method: 'PATCH', body: JSON.stringify(sourceProperties), }); @@ -83,12 +81,12 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const [updateSourceConfigurationRequest, updateSourceConfiguration] = useTrackedPromise( { - createPromise: async (sourceProperties: InfraSavedSourceConfiguration) => { + createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { if (!fetchService) { throw new Error(DEPENDENCY_ERROR_MESSAGE); } - return await fetchService(API_URL, { + return await fetchService(API_URL, { method: 'PATCH', body: JSON.stringify(sourceProperties), }); @@ -102,7 +100,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { [fetchService, sourceId] ); - const createDerivedIndexPattern = (type: 'logs' | 'metrics' | 'both') => { + const createDerivedIndexPattern = (type: 'metrics') => { return { fields: source?.status ? source.status.indexFields : [], title: pickIndexPattern(source, type), @@ -129,9 +127,6 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const sourceExists = useMemo(() => (source ? !!source.version : undefined), [source]); - const logIndicesExist = useMemo(() => source && source.status && source.status.logIndicesExist, [ - source, - ]); const metricIndicesExist = useMemo( () => source && source.status && source.status.metricIndicesExist, [source] @@ -144,7 +139,6 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { return { createSourceConfiguration, createDerivedIndexPattern, - logIndicesExist, isLoading, isLoadingSource: loadSourceRequest.state === 'pending', isUninitialized, diff --git a/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts b/x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts similarity index 62% rename from x-pack/plugins/infra/public/containers/source/use_source_via_http.ts rename to x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts index 548e6b8aa9cd9..2947f8fb09847 100644 --- a/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts +++ b/x-pack/plugins/infra/public/containers/metrics_source/use_source_via_http.ts @@ -13,51 +13,47 @@ import createContainer from 'constate'; import { HttpHandler } from 'src/core/public'; import { ToastInput } from 'src/core/public'; import { - SourceResponseRuntimeType, - SourceResponse, - InfraSource, -} from '../../../common/http_api/source_api'; + metricsSourceConfigurationResponseRT, + MetricsSourceConfigurationResponse, + MetricsSourceConfiguration, +} from '../../../common/metrics_sources'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; export const pickIndexPattern = ( - source: InfraSource | undefined, - type: 'logs' | 'metrics' | 'both' + source: MetricsSourceConfiguration | undefined, + type: 'metrics' ) => { if (!source) { return 'unknown-index'; } - if (type === 'logs') { - return source.configuration.logAlias; - } if (type === 'metrics') { return source.configuration.metricAlias; } - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; + return `${source.configuration.metricAlias}`; }; interface Props { sourceId: string; - type: 'logs' | 'metrics' | 'both'; fetch?: HttpHandler; toastWarning?: (input: ToastInput) => void; } -export const useSourceViaHttp = ({ - sourceId = 'default', - type = 'both', - fetch, - toastWarning, -}: Props) => { +export const useSourceViaHttp = ({ sourceId = 'default', fetch, toastWarning }: Props) => { const decodeResponse = (response: any) => { return pipe( - SourceResponseRuntimeType.decode(response), + metricsSourceConfigurationResponseRT.decode(response), fold(throwErrors(createPlainError), identity) ); }; - const { error, loading, response, makeRequest } = useHTTPRequest( - `/api/metrics/source/${sourceId}/${type}`, + const { + error, + loading, + response, + makeRequest, + } = useHTTPRequest( + `/api/metrics/source/${sourceId}`, 'GET', null, decodeResponse, @@ -71,15 +67,12 @@ export const useSourceViaHttp = ({ })(); }, [makeRequest]); - const createDerivedIndexPattern = useCallback( - (indexType: 'logs' | 'metrics' | 'both' = type) => { - return { - fields: response?.source.status ? response.source.status.indexFields : [], - title: pickIndexPattern(response?.source, indexType), - }; - }, - [response, type] - ); + const createDerivedIndexPattern = useCallback(() => { + return { + fields: response?.source.status ? response.source.status.indexFields : [], + title: pickIndexPattern(response?.source, 'metrics'), + }; + }, [response]); const source = useMemo(() => { return response ? response.source : null; diff --git a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx index 4c4835cbe4cdb..56a2a13e31ff7 100644 --- a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx +++ b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx @@ -17,10 +17,10 @@ import { useUrlState } from '../../utils/use_url_state'; import { useFindSavedObject } from '../../hooks/use_find_saved_object'; import { useCreateSavedObject } from '../../hooks/use_create_saved_object'; import { useDeleteSavedObject } from '../../hooks/use_delete_saved_object'; -import { Source } from '../source'; +import { Source } from '../metrics_source'; import { metricsExplorerViewSavedObjectName } from '../../../common/saved_objects/metrics_explorer_view'; import { inventoryViewSavedObjectName } from '../../../common/saved_objects/inventory_view'; -import { useSourceConfigurationFormState } from '../../components/source_configuration/source_configuration_form_state'; +import { useSourceConfigurationFormState } from '../../pages/metrics/settings/source_configuration_form_state'; import { useGetSavedObject } from '../../hooks/use_get_saved_object'; import { useUpdateSavedObject } from '../../hooks/use_update_saved_object'; diff --git a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx index 3b9f0d3e1eae2..f3ca57a40c4c7 100644 --- a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx +++ b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx @@ -9,17 +9,19 @@ import React, { useContext } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; import { - InfraSavedSourceConfiguration, - InfraSourceConfiguration, -} from '../../../common/http_api/source_api'; + MetricsSourceConfigurationProperties, + PartialMetricsSourceConfigurationProperties, +} from '../../../common/metrics_sources'; import { RendererFunction } from '../../utils/typed_react'; -import { Source } from '../source'; +import { Source } from '../metrics_source'; interface WithSourceProps { children: RendererFunction<{ - configuration?: InfraSourceConfiguration; - create: (sourceProperties: InfraSavedSourceConfiguration) => Promise | undefined; - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + configuration?: MetricsSourceConfigurationProperties; + create: ( + sourceProperties: PartialMetricsSourceConfigurationProperties + ) => Promise | undefined; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; exists?: boolean; hasFailed: boolean; isLoading: boolean; @@ -29,7 +31,9 @@ interface WithSourceProps { metricAlias?: string; metricIndicesExist?: boolean; sourceId: string; - update: (sourceProperties: InfraSavedSourceConfiguration) => Promise | undefined; + update: ( + sourceProperties: PartialMetricsSourceConfigurationProperties + ) => Promise | undefined; version?: string; }>; } @@ -42,7 +46,6 @@ export const WithSource: React.FunctionComponent = ({ children sourceExists, sourceId, metricIndicesExist, - logIndicesExist, isLoading, loadSource, hasFailedLoadingSource, @@ -60,7 +63,6 @@ export const WithSource: React.FunctionComponent = ({ children isLoading, lastFailureMessage: loadSourceFailureMessage, load: loadSource, - logIndicesExist, metricIndicesExist, sourceId, update: updateSourceConfiguration, diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index 622e0c9d33845..4541eb6518788 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -14,7 +14,7 @@ import { SnapshotNodeMetric, SnapshotNodePath, } from '../../common/http_api/snapshot_api'; -import { InfraSourceConfigurationFields } from '../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../common/metrics_sources'; import { WaffleSortOption } from '../pages/metrics/inventory_view/hooks/use_waffle_options'; export interface InfraWaffleMapNode { @@ -124,7 +124,7 @@ export enum InfraWaffleMapRuleOperator { } export interface InfraWaffleMapOptions { - fields?: InfraSourceConfigurationFields | null; + fields?: MetricsSourceConfigurationProperties['fields'] | null; formatter: InfraFormatterType; formatTemplate: string; metric: SnapshotMetricInput; diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 45b17aeb1f724..bcc2eec504209 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -14,9 +14,7 @@ export const createMetricsHasData = ( ) => async () => { const [coreServices] = await getStartServices(); const { http } = coreServices; - const results = await http.get<{ hasData: boolean }>( - '/api/metrics/source/default/metrics/hasData' - ); + const results = await http.get<{ hasData: boolean }>('/api/metrics/source/default/hasData'); return results.hasData; }; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx index ea2e67abc4141..8377eadfbce1d 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_detail_via_ip.tsx @@ -14,7 +14,7 @@ import { useHostIpToName } from './use_host_ip_to_name'; import { getFromFromLocation, getToFromLocation } from './query_params'; import { LoadingPage } from '../../components/loading_page'; import { Error } from '../error'; -import { useSource } from '../../containers/source/source'; +import { useSourceViaHttp } from '../../containers/metrics_source/use_source_via_http'; type RedirectToHostDetailType = RouteComponentProps<{ hostIp: string; @@ -26,7 +26,7 @@ export const RedirectToHostDetailViaIP = ({ }, location, }: RedirectToHostDetailType) => { - const { source } = useSource({ sourceId: 'default' }); + const { source } = useSourceViaHttp({ sourceId: 'default' }); const { error, name } = useHostIpToName( hostIp, diff --git a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx index 13eea67fb2a5a..236817ce3890f 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx @@ -19,7 +19,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface FieldsConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx index 72b5c35b958d6..e6f03e76255a2 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from '../../../components/source_configuration'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface IndicesConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx index 5d6ff9544e187..bc3bc22f3f1b2 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { NoIndices } from '../../../components/empty_states/no_indices'; -import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; +import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useLinkProps } from '../../../hooks/use_link_props'; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 240cb778275b1..51cc4ca098483 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -12,7 +12,7 @@ import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup, EuiButtonEmpty } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/common'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../common/metrics_sources'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -24,7 +24,7 @@ import { } from './metrics_explorer/hooks/use_metrics_explorer_options'; import { WithMetricsExplorerOptionsUrlState } from '../../containers/metrics_explorer/with_metrics_explorer_options_url_state'; import { WithSource } from '../../containers/with_source'; -import { Source } from '../../containers/source'; +import { Source } from '../../containers/metrics_source'; import { MetricsExplorerPage } from './metrics_explorer'; import { SnapshotPage } from './inventory_view'; import { MetricsSettingsPage } from './settings'; @@ -188,8 +188,8 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { }; const PageContent = (props: { - configuration: InfraSourceConfiguration; - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + configuration: MetricsSourceConfigurationProperties; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; }) => { const { createDerivedIndexPattern, configuration } = props; const { options } = useContext(MetricsExplorerOptionsContainer.Context); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 089ad9c237818..534132eb75fa1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -18,7 +18,7 @@ import { useSnapshot } from '../hooks/use_snaphot'; import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; import { DEFAULT_LEGEND, useWaffleOptionsContext } from '../hooks/use_waffle_options'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; import { InfraFormatterType } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Toolbar } from './toolbars/toolbar'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx index 7f0424cf48758..409c11cbbe897 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomalies_table/anomalies_table.tsx @@ -43,7 +43,7 @@ import { import { PaginationControls } from './pagination'; import { AnomalySummary } from './annomaly_summary'; import { AnomalySeverityIndicator } from '../../../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; -import { useSourceContext } from '../../../../../../../containers/source'; +import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { createResultsUrl } from '../flyout_home'; import { useWaffleViewState, WaffleViewState } from '../../../../hooks/use_waffle_view_state'; type JobType = 'k8s' | 'hosts'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx index 326689e945e1d..387e739fab43f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout.tsx @@ -13,7 +13,7 @@ import { JobSetupScreen } from './job_setup_screen'; import { useInfraMLCapabilities } from '../../../../../../containers/ml/infra_ml_capabilities'; import { MetricHostsModuleProvider } from '../../../../../../containers/ml/modules/metrics_hosts/module'; import { MetricK8sModuleProvider } from '../../../../../../containers/ml/modules/metrics_k8s/module'; -import { useSourceViaHttp } from '../../../../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../../../../containers/metrics_source/use_source_via_http'; import { useActiveKibanaSpace } from '../../../../../../hooks/use_kibana_space'; export const AnomalyDetectionFlyout = () => { @@ -23,7 +23,6 @@ export const AnomalyDetectionFlyout = () => { const [screenParams, setScreenParams] = useState(null); const { source } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', }); const { space } = useActiveKibanaSpace(); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx index 894f76318bcfe..a210831eef865 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx @@ -17,7 +17,7 @@ import moment, { Moment } from 'moment'; import { EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { useSourceViaHttp } from '../../../../../../containers/source/use_source_via_http'; +import { useSourceViaHttp } from '../../../../../../containers/metrics_source/use_source_via_http'; import { useMetricK8sModuleContext } from '../../../../../../containers/ml/modules/metrics_k8s/module'; import { useMetricHostsModuleContext } from '../../../../../../containers/ml/modules/metrics_hosts/module'; import { FixedDatePicker } from '../../../../../../components/fixed_datepicker'; @@ -42,7 +42,6 @@ export const JobSetupScreen = (props: Props) => { const [filterQuery, setFilterQuery] = useState(''); const { createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', - type: 'metrics', }); const indicies = h.sourceConfiguration.indices; @@ -79,7 +78,7 @@ export const JobSetupScreen = (props: Props) => { } }, [props.jobType, k.jobSummaries, h.jobSummaries]); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern(), [ createDerivedIndexPattern, ]); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index d89aaefe53fd1..5ab8eb380a657 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -23,7 +23,7 @@ import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/e import { TabContent, TabProps } from '../shared'; import { useSnapshot } from '../../../../hooks/use_snaphot'; import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options'; -import { useSourceContext } from '../../../../../../../containers/source'; +import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { findInventoryFields } from '../../../../../../../../common/inventory_models'; import { convertKueryToElasticSearchQuery } from '../../../../../../../utils/kuery'; import { SnapshotMetricType } from '../../../../../../../../common/inventory_models/types'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx index 9aa2cdfd90203..010a1a9941335 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiLoadingChart } from '@elastic/eui'; import { TabContent, TabProps } from '../shared'; -import { Source } from '../../../../../../../containers/source'; +import { Source } from '../../../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../../../common/inventory_models'; import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; import { useMetadata } from '../../../../../metric_detail/hooks/use_metadata'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx index cae17c174772d..16f73734836d0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/search_bar.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { Source } from '../../../../containers/source'; +import { Source } from '../../../../containers/metrics_source'; import { AutocompleteField } from '../../../../components/autocomplete_field'; import { WithKueryAutocompletion } from '../../../../containers/with_kuery_autocompletion'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx index 0248241d616dc..0a657b5242427 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx @@ -29,7 +29,7 @@ import { useUiSetting } from '../../../../../../../../../src/plugins/kibana_reac import { toMetricOpt } from '../../../../../../common/snapshot_metric_i18n'; import { MetricsExplorerAggregation } from '../../../../../../common/http_api'; import { colorTransformer, Color } from '../../../../../../common/color_palette'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { useTimeline } from '../../hooks/use_timeline'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; import { useWaffleTimeContext } from '../../hooks/use_waffle_time'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx index cd05341156831..1c79807f139c3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx @@ -7,7 +7,7 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexItem } from '@elastic/eui'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { SnapshotMetricInput, SnapshotGroupBy, @@ -24,7 +24,7 @@ import { WaffleOptionsState, WaffleSortOption } from '../../hooks/use_waffle_opt import { useInventoryMeta } from '../../hooks/use_inventory_meta'; export interface ToolbarProps extends Omit { - createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; + createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; changeMetric: (payload: SnapshotMetricInput) => void; changeGroupBy: (payload: SnapshotGroupBy) => void; changeCustomOptions: (payload: InfraGroupByOptions[]) => void; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx index abc0089e4fc2e..7fc332ead45c7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { fieldToName } from '../../lib/field_to_display_name'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; import { WaffleInventorySwitcher } from '../waffle/waffle_inventory_switcher'; import { ToolbarProps } from './toolbar'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index 523fa5f013b5a..6dde53efae761 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -17,7 +17,7 @@ import { InfraFormatterType, } from '../../../../../lib/lib'; -jest.mock('../../../../../containers/source', () => ({ +jest.mock('../../../../../containers/metrics_source', () => ({ useSourceContext: () => ({ sourceId: 'default' }), })); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx index d0aeeca9850c4..6e334f4fbca75 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx @@ -11,7 +11,7 @@ import { first } from 'lodash'; import { getCustomMetricLabel } from '../../../../../../common/formatters/get_custom_metric_label'; import { SnapshotCustomMetricInput } from '../../../../../../common/http_api'; import { withTheme, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { useSourceContext } from '../../../../../containers/source'; +import { useSourceContext } from '../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../common/inventory_models'; import { InventoryItemType, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts index d12bef2f3cdc0..e74abb2ecc459 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts @@ -13,7 +13,7 @@ import { useEffect, useState } from 'react'; import { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../../common/http_api'; import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; import { useHTTPRequest } from '../../../../hooks/use_http_request'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; export interface SortBy { name: string; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts index 8d7e516d50b57..cc1108cb91e6d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts @@ -17,7 +17,7 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('../../../../containers/source', () => ({ +jest.mock('../../../../containers/metrics_source', () => ({ useSourceContext: () => ({ createDerivedIndexPattern: () => 'jestbeat-*', }), diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts index 30c15410e1199..90cf96330e758 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts @@ -13,7 +13,7 @@ import { constant, identity } from 'fp-ts/lib/function'; import createContainter from 'constate'; import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; import { useUrlState } from '../../../../utils/use_url_state'; -import { useSourceContext } from '../../../../containers/source'; +import { useSourceContext } from '../../../../containers/metrics_source'; import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery'; import { esKuery } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 6b980d33c2559..57073fee13c18 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -17,8 +17,8 @@ import { ColumnarPage } from '../../../components/page'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; -import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; -import { Source } from '../../../containers/source'; +import { ViewSourceConfigurationButton } from '../../../components/source_configuration/view_source_configuration_button'; +import { Source } from '../../../containers/metrics_source'; import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Layout } from './components/layout'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts index 1e315f95dbd7c..dbe45a387891c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_uptime_link.test.ts @@ -14,7 +14,6 @@ const options: InfraWaffleMapOptions = { container: 'container.id', pod: 'kubernetes.pod.uid', host: 'host.name', - message: ['@message'], timestamp: '@timestanp', tiebreaker: '@timestamp', }, diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx index 6b9912346f396..2a436eac30b2c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/e import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; -import { ViewSourceConfigurationButton } from '../../../../components/source_configuration'; +import { ViewSourceConfigurationButton } from '../../../../components/source_configuration/view_source_configuration_button'; import { useLinkProps } from '../../../../hooks/use_link_props'; interface InvalidNodeErrorProps { diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index d174707d8b6c9..13fa5cf1f0667 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -17,7 +17,7 @@ import { Header } from '../../../components/header'; import { ColumnarPage, PageContent } from '../../../components/page'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from './hooks/use_metadata'; -import { Source } from '../../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { InfraLoadingPanel } from '../../../components/loading'; import { findInventoryModel } from '../../../../common/inventory_models'; import { NavItem } from './lib/side_nav_context'; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx index ac90e488cea94..c4e1b6bf8ef16 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx @@ -7,7 +7,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; -import { Source } from '../../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { MetricsTimeProvider } from './hooks/use_metrics_time'; export const withMetricPageProviders = (Component: React.ComponentType) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx index 442382010d78c..35265f0a462cf 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/charts'; import { first, last } from 'lodash'; import moment from 'moment'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -47,7 +47,7 @@ interface Props { options: MetricsExplorerOptions; chartOptions: MetricsExplorerChartOptions; series: MetricsExplorerSeries; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; timeRange: MetricsExplorerTimeOptions; onTimeChange: (start: string, end: string) => void; } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index f5970cffa157d..8f281bda0229d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import DateMath from '@elastic/datemath'; import { Capabilities } from 'src/core/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { AlertFlyout } from '../../../../alerting/metric_threshold/components/alert_flyout'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; import { @@ -33,14 +33,14 @@ export interface Props { options: MetricsExplorerOptions; onFilter?: (query: string) => void; series: MetricsExplorerSeries; - source?: InfraSourceConfiguration; + source?: MetricsSourceConfigurationProperties; timeRange: MetricsExplorerTimeOptions; uiCapabilities?: Capabilities; chartOptions: MetricsExplorerChartOptions; } const fieldToNodeType = ( - source: InfraSourceConfiguration, + source: MetricsSourceConfigurationProperties, groupBy: string | string[] ): InventoryItemType | undefined => { const fields = Array.isArray(groupBy) ? groupBy : [groupBy]; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx index e2e64a6758a29..68faaf1f45145 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/charts.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiFlexGrid, EuiFlexItem, EuiText, EuiHorizontalRule } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerResponse } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, @@ -31,7 +31,7 @@ interface Props { onFilter: (filter: string) => void; onTimeChange: (start: string, end: string) => void; data: MetricsExplorerResponse | null; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; timeRange: MetricsExplorerTimeOptions; } export const MetricsExplorerCharts = ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index d2eeada219fa4..1a549041823ec 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -8,7 +8,7 @@ import { encode } from 'rison-node'; import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; -import { InfraSourceConfiguration } from '../../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../../common/metrics_sources'; import { colorTransformer, Color } from '../../../../../../common/color_palette'; import { MetricsExplorerSeries } from '../../../../../../common/http_api/metrics_explorer'; import { @@ -143,7 +143,7 @@ const createTSVBIndexPattern = (alias: string) => { }; export const createTSVBLink = ( - source: InfraSourceConfiguration | undefined, + source: MetricsSourceConfigurationProperties | undefined, options: MetricsExplorerOptions, series: MetricsExplorerSeries, timeRange: MetricsExplorerTimeOptions, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index eb5a4633d4fa9..a304c81ca1298 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -7,7 +7,7 @@ import { useState, useCallback, useContext } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerMetric, MetricsExplorerAggregation, @@ -28,7 +28,7 @@ export interface MetricExplorerViewState { } export const useMetricsExplorerState = ( - source: InfraSourceConfiguration, + source: MetricsSourceConfigurationProperties, derivedIndexPattern: IIndexPattern, shouldLoadImmediately = true ) => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx index 3d09a907be12f..9a5e5fcf39ce4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx @@ -22,7 +22,7 @@ import { import { MetricsExplorerOptions, MetricsExplorerTimeOptions } from './use_metrics_explorer_options'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; import { HttpHandler } from 'kibana/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; const mockedFetch = jest.fn(); @@ -38,7 +38,7 @@ const renderUseMetricsExplorerDataHook = () => { return renderHook( (props: { options: MetricsExplorerOptions; - source: InfraSourceConfiguration | undefined; + source: MetricsSourceConfigurationProperties | undefined; derivedIndexPattern: IIndexPattern; timeRange: MetricsExplorerTimeOptions; afterKey: string | null | Record; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index b6620e963217d..6689aedcd7209 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -9,7 +9,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; import { useEffect, useState, useCallback } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerResponse, metricsExplorerResponseRT, @@ -25,7 +25,7 @@ function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOpt export function useMetricsExplorerData( options: MetricsExplorerOptions, - source: InfraSourceConfiguration | undefined, + source: MetricsSourceConfigurationProperties | undefined, derivedIndexPattern: IIndexPattern, timerange: MetricsExplorerTimeOptions, afterKey: string | null | Record, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 3eb9bbacddd2e..0d1ac47812577 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -9,7 +9,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; -import { InfraSourceConfiguration } from '../../../../common/http_api/source_api'; +import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useTrackPageview } from '../../../../../observability/public'; import { DocumentTitle } from '../../../components/document_title'; import { NoData } from '../../../components/empty_states'; @@ -19,7 +19,7 @@ import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; import { useSavedViewContext } from '../../../containers/saved_view/saved_view'; interface MetricsExplorerPageProps { - source: InfraSourceConfiguration; + source: MetricsSourceConfigurationProperties; derivedIndexPattern: IIndexPattern; } diff --git a/x-pack/plugins/infra/public/pages/metrics/settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings.tsx index c9be4abcf9e5f..c54725ab39754 100644 --- a/x-pack/plugins/infra/public/pages/metrics/settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings.tsx @@ -8,7 +8,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; +import { SourceConfigurationSettings } from './settings/source_configuration_settings'; export const MetricsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; diff --git a/x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx similarity index 98% rename from x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx index 2a8abdbc04f8e..7026f372ec7ff 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/fields_configuration_panel.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { InputFieldProps } from './input_fields'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface FieldsConfigurationPanelProps { containerFieldProps: InputFieldProps; diff --git a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts similarity index 91% rename from x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts rename to x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts index b4dede79d11f2..ad26c1b13b0e1 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_form_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_form_state.ts @@ -11,16 +11,14 @@ import { createInputFieldProps, createInputRangeFieldProps, validateInputFieldNotEmpty, -} from './input_fields'; +} from '../../../components/source_configuration/input_fields'; interface FormState { name: string; description: string; metricAlias: string; - logAlias: string; containerField: string; hostField: string; - messageField: string[]; podField: string; tiebreakerField: string; timestampField: string; @@ -56,16 +54,6 @@ export const useIndicesConfigurationFormState = ({ }), [formState.name] ); - const logAliasFieldProps = useMemo( - () => - createInputFieldProps({ - errors: validateInputFieldNotEmpty(formState.logAlias), - name: 'logAlias', - onChange: (logAlias) => setFormStateChanges((changes) => ({ ...changes, logAlias })), - value: formState.logAlias, - }), - [formState.logAlias] - ); const metricAliasFieldProps = useMemo( () => createInputFieldProps({ @@ -144,7 +132,6 @@ export const useIndicesConfigurationFormState = ({ const fieldProps = useMemo( () => ({ name: nameFieldProps, - logAlias: logAliasFieldProps, metricAlias: metricAliasFieldProps, containerField: containerFieldFieldProps, hostField: hostFieldFieldProps, @@ -155,7 +142,6 @@ export const useIndicesConfigurationFormState = ({ }), [ nameFieldProps, - logAliasFieldProps, metricAliasFieldProps, containerFieldFieldProps, hostFieldFieldProps, @@ -193,11 +179,9 @@ export const useIndicesConfigurationFormState = ({ const defaultFormState: FormState = { name: '', description: '', - logAlias: '', metricAlias: '', containerField: '', hostField: '', - messageField: [], podField: '', tiebreakerField: '', timestampField: '', diff --git a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx similarity index 93% rename from x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx index cff9b78777aa3..c64ab2b0e9df5 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/indices_configuration_panel.tsx @@ -17,8 +17,8 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { METRICS_INDEX_PATTERN } from '../../../common/constants'; -import { InputFieldProps } from './input_fields'; +import { METRICS_INDEX_PATTERN } from '../../../../common/constants'; +import { InputFieldProps } from '../../../components/source_configuration/input_fields'; interface IndicesConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx similarity index 96% rename from x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx index 3bd498d460391..abf25dde0ea99 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/ml_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/ml_configuration_panel.tsx @@ -13,7 +13,7 @@ import { EuiDescribedFormGroup } from '@elastic/eui'; import { EuiForm } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { InputRangeFieldProps } from './input_fields'; +import { InputRangeFieldProps } from '../../../components/source_configuration/input_fields'; interface MLConfigurationPanelProps { isLoading: boolean; diff --git a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx similarity index 57% rename from x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx index c80235137eea6..37da4bd1aa1bd 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_form_state.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_form_state.tsx @@ -6,12 +6,12 @@ */ import { useCallback, useMemo } from 'react'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; - +import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useIndicesConfigurationFormState } from './indices_configuration_form_state'; -import { useLogColumnsConfigurationFormState } from './log_columns_configuration_form_state'; -export const useSourceConfigurationFormState = (configuration?: InfraSourceConfiguration) => { +export const useSourceConfigurationFormState = ( + configuration?: MetricsSourceConfigurationProperties +) => { const indicesConfigurationFormState = useIndicesConfigurationFormState({ initialFormState: useMemo( () => @@ -19,11 +19,9 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi ? { name: configuration.name, description: configuration.description, - logAlias: configuration.logAlias, metricAlias: configuration.metricAlias, containerField: configuration.fields.container, hostField: configuration.fields.host, - messageField: configuration.fields.message, podField: configuration.fields.pod, tiebreakerField: configuration.fields.tiebreaker, timestampField: configuration.fields.timestamp, @@ -34,43 +32,26 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi ), }); - const logColumnsConfigurationFormState = useLogColumnsConfigurationFormState({ - initialFormState: useMemo( - () => - configuration - ? { - logColumns: configuration.logColumns, - } - : undefined, - [configuration] - ), - }); - - const errors = useMemo( - () => [...indicesConfigurationFormState.errors, ...logColumnsConfigurationFormState.errors], - [indicesConfigurationFormState.errors, logColumnsConfigurationFormState.errors] - ); + const errors = useMemo(() => [...indicesConfigurationFormState.errors], [ + indicesConfigurationFormState.errors, + ]); const resetForm = useCallback(() => { indicesConfigurationFormState.resetForm(); - logColumnsConfigurationFormState.resetForm(); - }, [indicesConfigurationFormState, logColumnsConfigurationFormState]); + }, [indicesConfigurationFormState]); - const isFormDirty = useMemo( - () => indicesConfigurationFormState.isFormDirty || logColumnsConfigurationFormState.isFormDirty, - [indicesConfigurationFormState.isFormDirty, logColumnsConfigurationFormState.isFormDirty] - ); + const isFormDirty = useMemo(() => indicesConfigurationFormState.isFormDirty, [ + indicesConfigurationFormState.isFormDirty, + ]); - const isFormValid = useMemo( - () => indicesConfigurationFormState.isFormValid && logColumnsConfigurationFormState.isFormValid, - [indicesConfigurationFormState.isFormValid, logColumnsConfigurationFormState.isFormValid] - ); + const isFormValid = useMemo(() => indicesConfigurationFormState.isFormValid, [ + indicesConfigurationFormState.isFormValid, + ]); const formState = useMemo( () => ({ name: indicesConfigurationFormState.formState.name, description: indicesConfigurationFormState.formState.description, - logAlias: indicesConfigurationFormState.formState.logAlias, metricAlias: indicesConfigurationFormState.formState.metricAlias, fields: { container: indicesConfigurationFormState.formState.containerField, @@ -79,17 +60,15 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi tiebreaker: indicesConfigurationFormState.formState.tiebreakerField, timestamp: indicesConfigurationFormState.formState.timestampField, }, - logColumns: logColumnsConfigurationFormState.formState.logColumns, anomalyThreshold: indicesConfigurationFormState.formState.anomalyThreshold, }), - [indicesConfigurationFormState.formState, logColumnsConfigurationFormState.formState] + [indicesConfigurationFormState.formState] ); const formStateChanges = useMemo( () => ({ name: indicesConfigurationFormState.formStateChanges.name, description: indicesConfigurationFormState.formStateChanges.description, - logAlias: indicesConfigurationFormState.formStateChanges.logAlias, metricAlias: indicesConfigurationFormState.formStateChanges.metricAlias, fields: { container: indicesConfigurationFormState.formStateChanges.containerField, @@ -98,25 +77,18 @@ export const useSourceConfigurationFormState = (configuration?: InfraSourceConfi tiebreaker: indicesConfigurationFormState.formStateChanges.tiebreakerField, timestamp: indicesConfigurationFormState.formStateChanges.timestampField, }, - logColumns: logColumnsConfigurationFormState.formStateChanges.logColumns, anomalyThreshold: indicesConfigurationFormState.formStateChanges.anomalyThreshold, }), - [ - indicesConfigurationFormState.formStateChanges, - logColumnsConfigurationFormState.formStateChanges, - ] + [indicesConfigurationFormState.formStateChanges] ); return { - addLogColumn: logColumnsConfigurationFormState.addLogColumn, - moveLogColumn: logColumnsConfigurationFormState.moveLogColumn, errors, formState, formStateChanges, isFormDirty, isFormValid, indicesConfigurationProps: indicesConfigurationFormState.fieldProps, - logColumnConfigurationProps: logColumnsConfigurationFormState.logColumnConfigurationProps, resetForm, }; }; diff --git a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx similarity index 94% rename from x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx rename to x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx index e63f43470497d..71fa4e7600503 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings/source_configuration_settings.tsx @@ -19,15 +19,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useContext, useMemo } from 'react'; -import { Source } from '../../containers/source'; +import { Source } from '../../../containers/metrics_source'; import { FieldsConfigurationPanel } from './fields_configuration_panel'; import { IndicesConfigurationPanel } from './indices_configuration_panel'; -import { NameConfigurationPanel } from './name_configuration_panel'; +import { NameConfigurationPanel } from '../../../components/source_configuration/name_configuration_panel'; import { useSourceConfigurationFormState } from './source_configuration_form_state'; -import { SourceLoadingPage } from '../source_loading_page'; -import { Prompt } from '../../utils/navigation_warning_prompt'; +import { SourceLoadingPage } from '../../../components/source_loading_page'; +import { Prompt } from '../../../utils/navigation_warning_prompt'; import { MLConfigurationPanel } from './ml_configuration_panel'; -import { useInfraMLCapabilitiesContext } from '../../containers/ml/infra_ml_capabilities'; +import { useInfraMLCapabilitiesContext } from '../../../containers/ml/infra_ml_capabilities'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 4d70676d25e40..068abd0e0f20f 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -19,8 +19,8 @@ import type { } from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import type { - ObservabilityPluginSetup, - ObservabilityPluginStart, + ObservabilityPublicSetup, + ObservabilityPublicStart, } from '../../observability/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { MlPluginStart, MlPluginSetup } from '../../ml/public'; @@ -33,7 +33,7 @@ export type InfraClientStartExports = void; export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; home?: HomePublicPluginSetup; - observability: ObservabilityPluginSetup; + observability: ObservabilityPublicSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; ml: MlPluginSetup; @@ -43,7 +43,7 @@ export interface InfraClientSetupDeps { export interface InfraClientStartDeps { data: DataPublicPluginStart; dataEnhanced: DataEnhancedStart; - observability: ObservabilityPluginStart; + observability: ObservabilityPublicStart; spaces: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection: UsageCollectionStart; diff --git a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx index 124b6b8f13bf9..33fbbd03d790a 100644 --- a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx +++ b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { act as reactAct } from 'react-dom/test-utils'; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 5369deb1034ee..43f0b12a23f23 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -6,7 +6,7 @@ */ import { encode } from 'rison-node'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { FetchData, FetchDataParams, LogsFetchDataResponse } from '../../../observability/public'; import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; @@ -81,7 +81,7 @@ async function fetchLogsOverview( dataPlugin: InfraClientStartDeps['data'] ): Promise { return new Promise((resolve, reject) => { - let esResponse: SearchResponse | undefined; + let esResponse: estypes.SearchResponse | undefined; dataPlugin.search .search({ @@ -99,7 +99,7 @@ async function fetchLogsOverview( (error) => reject(error), () => { if (esResponse?.aggregations) { - resolve(processLogsOverviewAggregations(esResponse!.aggregations)); + resolve(processLogsOverviewAggregations(esResponse!.aggregations as any)); } else { resolve({ stats: {}, series: {} }); } diff --git a/x-pack/plugins/infra/public/utils/source_configuration.ts b/x-pack/plugins/infra/public/utils/source_configuration.ts index b7b45d1927711..a3e1741c7590b 100644 --- a/x-pack/plugins/infra/public/utils/source_configuration.ts +++ b/x-pack/plugins/infra/public/utils/source_configuration.ts @@ -6,14 +6,14 @@ */ import { - InfraSavedSourceConfigurationColumn, - InfraSavedSourceConfigurationFields, + InfraSourceConfigurationColumn, + InfraSourceConfigurationFieldColumn, InfraSourceConfigurationMessageColumn, InfraSourceConfigurationTimestampColumn, -} from '../../common/http_api/source_api'; +} from '../../common/source_configuration/source_configuration'; -export type LogColumnConfiguration = InfraSavedSourceConfigurationColumn; -export type FieldLogColumnConfiguration = InfraSavedSourceConfigurationFields; +export type LogColumnConfiguration = InfraSourceConfigurationColumn; +export type FieldLogColumnConfiguration = InfraSourceConfigurationFieldColumn; export type MessageLogColumnConfiguration = InfraSourceConfigurationMessageColumn; export type TimestampLogColumnConfiguration = InfraSourceConfigurationTimestampColumn; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 69595c90c7911..f42207e0ad142 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -32,7 +32,7 @@ import { } from './routes/log_entries'; import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; -import { initSourceRoute } from './routes/source'; +import { initMetricsSourceConfigurationRoutes } from './routes/metrics_sources'; import { initOverviewRoute } from './routes/overview'; import { initAlertPreviewRoute } from './routes/alerting'; import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts'; @@ -50,7 +50,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initGetHostsAnomaliesRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); - initSourceRoute(libs); + initMetricsSourceConfigurationRoutes(libs); initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initGetLogEntryExamplesRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index 2cb00644f56d4..451b2284ba310 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -9,9 +9,9 @@ import { IndicesExistsAlias, IndicesGet, MlGetBuckets, - Msearch, } from '@elastic/elasticsearch/api/requestParams'; import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; +import { estypes } from '@elastic/elasticsearch'; import { InfraRouteConfig, InfraTSVBResponse, @@ -153,7 +153,7 @@ export class KibanaFramework { apiResult = elasticsearch.client.asCurrentUser.msearch({ ...params, ...frozenIndicesParams, - } as Msearch); + } as estypes.MultiSearchRequest); break; case 'fieldCaps': apiResult = elasticsearch.client.asCurrentUser.fieldCaps({ diff --git a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index e390d6525cd60..921634361f4a2 100644 --- a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -34,7 +34,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { options: InfraMetricsRequestOptions, rawRequest: KibanaRequest ): Promise { - const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; + const indexPattern = `${options.sourceConfiguration.metricAlias}`; const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); const nodeField = fields.id; @@ -112,7 +112,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { ); } - const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; + const indexPattern = `${options.sourceConfiguration.metricAlias}`; const timerange = { min: options.timerange.from, max: options.timerange.to, @@ -132,7 +132,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { const calculatedInterval = await calculateMetricInterval( client, { - indexPattern: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`, + indexPattern: `${options.sourceConfiguration.metricAlias}`, timestampField: options.sourceConfiguration.fields.timestamp, timerange: options.timerange, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 615de182662f1..5244b8a81e75f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -23,6 +23,7 @@ import { InfraTimerangeInput, SnapshotRequest } from '../../../../common/http_ap import { InfraSource } from '../../sources'; import { UNGROUPED_FACTORY_KEY } from '../common/utils'; import { getNodes } from '../../../routes/snapshot/lib/get_nodes'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean[]; @@ -36,6 +37,7 @@ export const evaluateCondition = async ( condition: InventoryMetricConditions, nodeType: InventoryItemType, source: InfraSource, + logQueryFields: LogQueryFields, esClient: ElasticsearchClient, filterQuery?: string, lookbackSize?: number @@ -58,6 +60,7 @@ export const evaluateCondition = async ( metric, timerange, source, + logQueryFields, filterQuery, customMetric ); @@ -101,12 +104,14 @@ const getData = async ( metric: SnapshotMetricType, timerange: InfraTimerangeInput, source: InfraSource, + logQueryFields: LogQueryFields, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { const client = async ( options: CallWithRequestParams ): Promise> => + // @ts-expect-error @elastic/elasticsearch SearchResponse.body.timeout is not required (await esClient.search(options)).body as InfraDatabaseSearchResponse; const metrics = [ @@ -123,7 +128,7 @@ const getData = async ( includeTimeseries: Boolean(timerange.lookbackSize), }; try { - const { nodes } = await getNodes(client, snapshotRequest, source); + const { nodes } = await getNodes(client, snapshotRequest, source, logQueryFields); if (!nodes.length) return { [UNGROUPED_FACTORY_KEY]: null }; // No Data state diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 632ba9cd6f282..d775a503d1d32 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -68,12 +68,18 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = sourceId || 'default' ); + const logQueryFields = await libs.getLogQueryFields( + sourceId || 'default', + services.savedObjectsClient + ); + const results = await Promise.all( criteria.map((c) => evaluateCondition( c, nodeType, source, + logQueryFields, services.scopedClusterClient.asCurrentUser, filterQuery ) diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index 472f9d408694c..f254f1e68ae46 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -14,10 +14,11 @@ import { isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; import { ElasticsearchClient } from '../../../../../../../src/core/server'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../common/source_configuration/source_configuration'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InventoryItemType } from '../../../../common/inventory_models/types'; import { evaluateCondition } from './evaluate_condition'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; interface InventoryMetricThresholdParams { criteria: InventoryMetricConditions[]; @@ -30,6 +31,7 @@ interface PreviewInventoryMetricThresholdAlertParams { esClient: ElasticsearchClient; params: InventoryMetricThresholdParams; source: InfraSource; + logQueryFields: LogQueryFields; lookback: Unit; alertInterval: string; alertThrottle: string; @@ -43,6 +45,7 @@ export const previewInventoryMetricThresholdAlert: ( esClient, params, source, + logQueryFields, lookback, alertInterval, alertThrottle, @@ -68,7 +71,7 @@ export const previewInventoryMetricThresholdAlert: ( try { const results = await Promise.all( criteria.map((c) => - evaluateCondition(c, nodeType, source, esClient, filterQuery, lookbackSize) + evaluateCondition(c, nodeType, source, logQueryFields, esClient, filterQuery, lookbackSize) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index b7d3dbb1f7adb..87150aa134837 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -11,7 +11,7 @@ import { isTooManyBucketsPreviewException, TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, } from '../../../../../common/alerting/metrics'; -import { InfraSource } from '../../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../../common/source_configuration/source_configuration'; import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; @@ -127,6 +127,7 @@ const getMetric: ( (response) => response.aggregations?.groupings?.after_key ); const compositeBuckets = (await getAllCompositeData( + // @ts-expect-error @elastic/elasticsearch SearchResponse.body.timeout is not required (body) => esClient.search({ body, index }), searchBody, bucketSelector, @@ -147,7 +148,12 @@ const getMetric: ( index, }); - return { [UNGROUPED_FACTORY_KEY]: getValuesFromAggregations(result.aggregations, aggType) }; + return { + [UNGROUPED_FACTORY_KEY]: getValuesFromAggregations( + (result.aggregations! as unknown) as Aggregation, + aggType + ), + }; } catch (e) { if (timeframe) { // This code should only ever be reached when previewing the alert, not executing it diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index 064804b661b74..a4c207f4006d5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -12,7 +12,7 @@ import { isTooManyBucketsPreviewException, } from '../../../../common/alerting/metrics'; import { ElasticsearchClient } from '../../../../../../../src/core/server'; -import { InfraSource } from '../../../../common/http_api/source_api'; +import { InfraSource } from '../../../../common/source_configuration/source_configuration'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { PreviewResult } from '../common/types'; import { MetricExpressionParams } from './types'; diff --git a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts index b653351a34760..d5ffa56987666 100644 --- a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts @@ -18,21 +18,16 @@ export class InfraFieldsDomain { public async getFields( requestContext: InfraPluginRequestHandlerContext, sourceId: string, - indexType: 'LOGS' | 'METRICS' | 'ANY' + indexType: 'LOGS' | 'METRICS' ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId ); - const includeMetricIndices = ['ANY', 'METRICS'].includes(indexType); - const includeLogIndices = ['ANY', 'LOGS'].includes(indexType); const fields = await this.adapter.getIndexFields( requestContext, - [ - ...(includeMetricIndices ? [configuration.metricAlias] : []), - ...(includeLogIndices ? [configuration.logAlias] : []), - ].join(',') + indexType === 'LOGS' ? configuration.logAlias : configuration.metricAlias ); return fields; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index e3c42c4dceede..278ae0e086cfc 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -17,7 +17,7 @@ import { LogColumn, LogEntryCursor, LogEntry } from '../../../../common/log_entr import { InfraSourceConfiguration, InfraSources, - SavedSourceConfigurationFieldColumnRuntimeType, + SourceConfigurationFieldColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from '../../../services/log_entries/message/builtin_rules'; import { @@ -349,7 +349,7 @@ const getRequiredFields = ( ): string[] => { const fieldsFromCustomColumns = configuration.logColumns.reduce( (accumulatedFields, logColumn) => { - if (SavedSourceConfigurationFieldColumnRuntimeType.is(logColumn)) { + if (SourceConfigurationFieldColumnRuntimeType.is(logColumn)) { return [...accumulatedFields, logColumn.fieldColumn.field]; } return accumulatedFields; diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 65bb5f878b275..08e42279e4939 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { InfraSourceConfiguration } from '../../common/http_api/source_api'; +import { InfraSourceConfiguration } from '../../common/source_configuration/source_configuration'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; @@ -13,6 +13,7 @@ import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; import { InfraConfig } from '../plugin'; import { KibanaFramework } from './adapters/framework/kibana_framework_adapter'; +import { GetLogQueryFields } from '../services/log_queries/get_log_query_fields'; export interface InfraDomainLibs { fields: InfraFieldsDomain; @@ -25,6 +26,7 @@ export interface InfraBackendLibs extends InfraDomainLibs { framework: KibanaFramework; sources: InfraSources; sourceStatus: InfraSourceStatus; + getLogQueryFields: GetLogQueryFields; } export interface InfraConfiguration { diff --git a/x-pack/plugins/infra/server/lib/metrics/index.ts b/x-pack/plugins/infra/server/lib/metrics/index.ts index cb89c5a6b1bd3..e436ad2ba0b05 100644 --- a/x-pack/plugins/infra/server/lib/metrics/index.ts +++ b/x-pack/plugins/infra/server/lib/metrics/index.ts @@ -120,5 +120,5 @@ export const query = async ( ThrowReporter.report(HistogramResponseRT.decode(response.aggregations)); } - throw new Error('Elasticsearch responsed with an unrecoginzed format.'); + throw new Error('Elasticsearch responded with an unrecognized format.'); }; diff --git a/x-pack/plugins/infra/server/lib/sources/defaults.ts b/x-pack/plugins/infra/server/lib/sources/defaults.ts index 1b924619a905c..ff6d6a4f5514b 100644 --- a/x-pack/plugins/infra/server/lib/sources/defaults.ts +++ b/x-pack/plugins/infra/server/lib/sources/defaults.ts @@ -10,7 +10,7 @@ import { LOGS_INDEX_PATTERN, TIMESTAMP_FIELD, } from '../../../common/constants'; -import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; +import { InfraSourceConfiguration } from '../../../common/source_configuration/source_configuration'; export const defaultSourceConfiguration: InfraSourceConfiguration = { name: 'Default', diff --git a/x-pack/plugins/infra/server/lib/sources/index.ts b/x-pack/plugins/infra/server/lib/sources/index.ts index 57852f7f3e4e6..27ad665be31a9 100644 --- a/x-pack/plugins/infra/server/lib/sources/index.ts +++ b/x-pack/plugins/infra/server/lib/sources/index.ts @@ -8,4 +8,4 @@ export * from './defaults'; export { infraSourceConfigurationSavedObjectType } from './saved_object_type'; export * from './sources'; -export * from '../../../common/http_api/source_api'; +export * from '../../../common/source_configuration/source_configuration'; diff --git a/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts b/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts index dbfe0f81c187a..e71994fe11517 100644 --- a/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts +++ b/x-pack/plugins/infra/server/lib/sources/migrations/7_9_0_add_new_indexing_strategy_index_names.ts @@ -6,7 +6,7 @@ */ import { SavedObjectMigrationFn } from 'src/core/server'; -import { InfraSourceConfiguration } from '../../../../common/http_api/source_api'; +import { InfraSourceConfiguration } from '../../../../common/source_configuration/source_configuration'; export const addNewIndexingStrategyIndexNames: SavedObjectMigrationFn< InfraSourceConfiguration, diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index fe005b04978da..7abbed0a9fbdd 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -23,7 +23,7 @@ import { SourceConfigurationSavedObjectRuntimeType, StaticSourceConfigurationRuntimeType, InfraSource, -} from '../../../common/http_api/source_api'; +} from '../../../common/source_configuration/source_configuration'; import { InfraConfig } from '../../../server'; interface Libs { diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index c80e012844c1e..50fec38b9f2df 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -9,7 +9,7 @@ import { Server } from '@hapi/hapi'; import { schema, TypeOf } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { CoreSetup, PluginInitializerContext, Plugin } from 'src/core/server'; -import { InfraStaticSourceConfiguration } from '../common/http_api/source_api'; +import { InfraStaticSourceConfiguration } from '../common/source_configuration/source_configuration'; import { inventoryViewSavedObjectType } from '../common/saved_objects/inventory_view'; import { metricsExplorerViewSavedObjectType } from '../common/saved_objects/metrics_explorer_view'; import { LOGS_FEATURE, METRICS_FEATURE } from './features'; @@ -30,6 +30,7 @@ import { InfraSourceStatus } from './lib/source_status'; import { LogEntriesService } from './services/log_entries'; import { InfraPluginRequestHandlerContext } from './types'; import { UsageCollector } from './usage/usage_collector'; +import { createGetLogQueryFields } from './services/log_queries/get_log_query_fields'; export const config = { schema: schema.object({ @@ -123,6 +124,7 @@ export class InfraServerPlugin implements Plugin { sources, sourceStatus, ...domainLibs, + getLogQueryFields: createGetLogQueryFields(sources), }; plugins.features.registerKibanaFeature(METRICS_FEATURE); diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 6622df1a8333a..4d980834d3a70 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -25,7 +25,11 @@ import { previewMetricAnomalyAlert } from '../../lib/alerting/metric_anomaly/pre import { InfraBackendLibs } from '../../lib/infra_types'; import { assertHasInfraMlPlugins } from '../../utils/request_context'; -export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => { +export const initAlertPreviewRoute = ({ + framework, + sources, + getLogQueryFields, +}: InfraBackendLibs) => { framework.registerRoute( { method: 'post', @@ -77,6 +81,10 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { + const logQueryFields = await getLogQueryFields( + sourceId || 'default', + requestContext.core.savedObjects.client + ); const { nodeType, criteria, @@ -87,6 +95,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) params: { criteria, filterQuery, nodeType }, lookback, source, + logQueryFields, alertInterval, alertThrottle, alertNotifyWhen, diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts new file mode 100644 index 0000000000000..cc5631ffcec9c --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_grouping.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { queryTotalGroupings } from './query_total_groupings'; + +describe('queryTotalGroupings', () => { + const ESSearchClientMock = jest.fn().mockReturnValue({}); + const defaultOptions = { + timerange: { + from: 1615972672011, + interval: '>=10s', + to: 1615976272012, + field: '@timestamp', + }, + indexPattern: 'testIndexPattern', + metrics: [], + dropLastBucket: true, + groupBy: ['testField'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 0 when there is no groupBy', async () => { + const { groupBy, ...options } = defaultOptions; + + const response = await queryTotalGroupings(ESSearchClientMock, options); + expect(response).toBe(0); + }); + + it('should return 0 when there is groupBy is empty', async () => { + const options = { + ...defaultOptions, + groupBy: [], + }; + + const response = await queryTotalGroupings(ESSearchClientMock, options); + expect(response).toBe(0); + }); + + it('should query ES with a timerange', async () => { + await queryTotalGroupings(ESSearchClientMock, defaultOptions); + + expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({ + range: { + '@timestamp': { + gte: 1615972672011, + lte: 1615976272012, + format: 'epoch_millis', + }, + }, + }); + }); + + it('should query ES with a exist fields', async () => { + const options = { + ...defaultOptions, + groupBy: ['testField1', 'testField2'], + }; + + await queryTotalGroupings(ESSearchClientMock, options); + + expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({ + exists: { field: 'testField1' }, + }); + + expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({ + exists: { field: 'testField2' }, + }); + }); + + it('should query ES with a query filter', async () => { + const options = { + ...defaultOptions, + filters: [ + { + bool: { + should: [{ match_phrase: { field1: 'value1' } }], + minimum_should_match: 1, + }, + }, + ], + }; + + await queryTotalGroupings(ESSearchClientMock, options); + + expect(ESSearchClientMock.mock.calls[0][0].body.query.bool.filter).toContainEqual({ + bool: { + should: [ + { + match_phrase: { + field1: 'value1', + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('should return 0 when there are no aggregations in the response', async () => { + const clientMock = jest.fn().mockReturnValue({}); + + const response = await queryTotalGroupings(clientMock, defaultOptions); + + expect(response).toBe(0); + }); + + it('should return the value of the aggregation in the response', async () => { + const clientMock = jest.fn().mockReturnValue({ + aggregations: { + count: { + value: 10, + }, + }, + }); + + const response = await queryTotalGroupings(clientMock, defaultOptions); + + expect(response).toBe(10); + }); +}); diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts index 92aa39d3bf820..b871fa21c111d 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts @@ -23,6 +23,23 @@ export const queryTotalGroupings = async ( return Promise.resolve(0); } + let filters: Array> = [ + { + range: { + [options.timerange.field]: { + gte: options.timerange.from, + lte: options.timerange.to, + format: 'epoch_millis', + }, + }, + }, + ...options.groupBy.map((field) => ({ exists: { field } })), + ]; + + if (options.filters) { + filters = [...filters, ...options.filters]; + } + const params = { allowNoIndices: true, ignoreUnavailable: true, @@ -31,18 +48,7 @@ export const queryTotalGroupings = async ( size: 0, query: { bool: { - filter: [ - { - range: { - [options.timerange.field]: { - gte: options.timerange.from, - lte: options.timerange.to, - format: 'epoch_millis', - }, - }, - }, - ...options.groupBy.map((field) => ({ exists: { field } })), - ], + filter: filters, }, }, aggs: { diff --git a/x-pack/plugins/infra/server/routes/source/index.ts b/x-pack/plugins/infra/server/routes/metrics_sources/index.ts similarity index 69% rename from x-pack/plugins/infra/server/routes/source/index.ts rename to x-pack/plugins/infra/server/routes/metrics_sources/index.ts index 5ab3275f9ea9e..0123e4678697c 100644 --- a/x-pack/plugins/infra/server/routes/source/index.ts +++ b/x-pack/plugins/infra/server/routes/metrics_sources/index.ts @@ -8,63 +8,49 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { createValidationFunction } from '../../../common/runtime_types'; -import { - InfraSourceStatus, - SavedSourceConfigurationRuntimeType, - SourceResponseRuntimeType, -} from '../../../common/http_api/source_api'; import { InfraBackendLibs } from '../../lib/infra_types'; import { hasData } from '../../lib/sources/has_data'; import { createSearchClient } from '../../lib/create_search_client'; import { AnomalyThresholdRangeError } from '../../lib/sources/errors'; +import { + partialMetricsSourceConfigurationPropertiesRT, + metricsSourceConfigurationResponseRT, + MetricsSourceStatus, +} from '../../../common/metrics_sources'; -const typeToInfraIndexType = (value: string | undefined) => { - switch (value) { - case 'metrics': - return 'METRICS'; - case 'logs': - return 'LOGS'; - default: - return 'ANY'; - } -}; - -export const initSourceRoute = (libs: InfraBackendLibs) => { +export const initMetricsSourceConfigurationRoutes = (libs: InfraBackendLibs) => { const { framework } = libs; framework.registerRoute( { method: 'get', - path: '/api/metrics/source/{sourceId}/{type?}', + path: '/api/metrics/source/{sourceId}', validate: { params: schema.object({ sourceId: schema.string(), - type: schema.string(), }), }, }, async (requestContext, request, response) => { - const { type, sourceId } = request.params; + const { sourceId } = request.params; - const [source, logIndexStatus, metricIndicesExist, indexFields] = await Promise.all([ + const [source, metricIndicesExist, indexFields] = await Promise.all([ libs.sources.getSourceConfiguration(requestContext.core.savedObjects.client, sourceId), - libs.sourceStatus.getLogIndexStatus(requestContext, sourceId), libs.sourceStatus.hasMetricIndices(requestContext, sourceId), - libs.fields.getFields(requestContext, sourceId, typeToInfraIndexType(type)), + libs.fields.getFields(requestContext, sourceId, 'METRICS'), ]); if (!source) { return response.notFound(); } - const status: InfraSourceStatus = { - logIndicesExist: logIndexStatus !== 'missing', + const status: MetricsSourceStatus = { metricIndicesExist, indexFields, }; return response.ok({ - body: SourceResponseRuntimeType.encode({ source: { ...source, status } }), + body: metricsSourceConfigurationResponseRT.encode({ source: { ...source, status } }), }); } ); @@ -77,7 +63,7 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { params: schema.object({ sourceId: schema.string(), }), - body: createValidationFunction(SavedSourceConfigurationRuntimeType), + body: createValidationFunction(partialMetricsSourceConfigurationPropertiesRT), }, }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { @@ -110,20 +96,18 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { patchedSourceConfigurationProperties )); - const [logIndexStatus, metricIndicesExist, indexFields] = await Promise.all([ - libs.sourceStatus.getLogIndexStatus(requestContext, sourceId), + const [metricIndicesExist, indexFields] = await Promise.all([ libs.sourceStatus.hasMetricIndices(requestContext, sourceId), - libs.fields.getFields(requestContext, sourceId, typeToInfraIndexType('metrics')), + libs.fields.getFields(requestContext, sourceId, 'METRICS'), ]); - const status: InfraSourceStatus = { - logIndicesExist: logIndexStatus !== 'missing', + const status: MetricsSourceStatus = { metricIndicesExist, indexFields, }; return response.ok({ - body: SourceResponseRuntimeType.encode({ + body: metricsSourceConfigurationResponseRT.encode({ source: { ...patchedSourceConfiguration, status }, }), }); @@ -154,25 +138,23 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { framework.registerRoute( { method: 'get', - path: '/api/metrics/source/{sourceId}/{type}/hasData', + path: '/api/metrics/source/{sourceId}/hasData', validate: { params: schema.object({ sourceId: schema.string(), - type: schema.string(), }), }, }, async (requestContext, request, response) => { - const { type, sourceId } = request.params; + const { sourceId } = request.params; const client = createSearchClient(requestContext, framework); const source = await libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId ); - const indexPattern = - type === 'metrics' ? source.configuration.metricAlias : source.configuration.logAlias; - const results = await hasData(indexPattern, client); + + const results = await hasData(source.configuration.metricAlias, client); return response.ok({ body: { hasData: results }, diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index aaf23085d0d60..cbadd26ccd4bf 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -41,9 +41,15 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { snapshotRequest.sourceId ); + const logQueryFields = await libs.getLogQueryFields( + snapshotRequest.sourceId, + requestContext.core.savedObjects.client + ); + UsageCollector.countNode(snapshotRequest.nodeType); const client = createSearchClient(requestContext, framework); - const snapshotResponse = await getNodes(client, snapshotRequest, source); + + const snapshotResponse = await getNodes(client, snapshotRequest, source, logQueryFields); return response.ok({ body: SnapshotNodeResponseRT.encode(snapshotResponse), diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts deleted file mode 100644 index 85c1ece1ca042..0000000000000 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts +++ /dev/null @@ -1,23 +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 { SnapshotRequest } from '../../../../common/http_api'; -import { InfraSource } from '../../../lib/sources'; - -export const calculateIndexPatterBasedOnMetrics = ( - options: SnapshotRequest, - source: InfraSource -) => { - const { metrics } = options; - if (metrics.every((m) => m.type === 'logRate')) { - return source.configuration.logAlias; - } - if (metrics.some((m) => m.type === 'logRate')) { - return `${source.configuration.logAlias},${source.configuration.metricAlias}`; - } - return source.configuration.metricAlias; -}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts index 9dec21d3ab1c7..ff3cf048b99de 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts @@ -12,16 +12,24 @@ import { transformRequestToMetricsAPIRequest } from './transform_request_to_metr import { queryAllData } from './query_all_data'; import { transformMetricsApiResponseToSnapshotResponse } from './trasform_metrics_ui_response'; import { copyMissingMetrics } from './copy_missing_metrics'; +import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; -export const getNodes = async ( +export interface SourceOverrides { + indexPattern: string; + timestamp: string; +} + +const transformAndQueryData = async ( client: ESSearchClient, snapshotRequest: SnapshotRequest, - source: InfraSource + source: InfraSource, + sourceOverrides?: SourceOverrides ) => { const metricsApiRequest = await transformRequestToMetricsAPIRequest( client, source, - snapshotRequest + snapshotRequest, + sourceOverrides ); const metricsApiResponse = await queryAllData(client, metricsApiRequest); const snapshotResponse = transformMetricsApiResponseToSnapshotResponse( @@ -32,3 +40,59 @@ export const getNodes = async ( ); return copyMissingMetrics(snapshotResponse); }; + +export const getNodes = async ( + client: ESSearchClient, + snapshotRequest: SnapshotRequest, + source: InfraSource, + logQueryFields: LogQueryFields +) => { + let nodes; + + if (snapshotRequest.metrics.find((metric) => metric.type === 'logRate')) { + // *Only* the log rate metric has been requested + if (snapshotRequest.metrics.length === 1) { + nodes = await transformAndQueryData(client, snapshotRequest, source, logQueryFields); + } else { + // A scenario whereby a single host might be shipping metrics and logs. + const metricsWithoutLogsMetrics = snapshotRequest.metrics.filter( + (metric) => metric.type !== 'logRate' + ); + const nodesWithoutLogsMetrics = await transformAndQueryData( + client, + { ...snapshotRequest, metrics: metricsWithoutLogsMetrics }, + source + ); + const logRateNodes = await transformAndQueryData( + client, + { ...snapshotRequest, metrics: [{ type: 'logRate' }] }, + source, + logQueryFields + ); + // Merge nodes where possible - e.g. a single host is shipping metrics and logs + const mergedNodes = nodesWithoutLogsMetrics.nodes.map((node) => { + const logRateNode = logRateNodes.nodes.find( + (_logRateNode) => node.name === _logRateNode.name + ); + if (logRateNode) { + // Remove this from the "leftovers" + logRateNodes.nodes.filter((_node) => _node.name !== logRateNode.name); + } + return logRateNode + ? { + ...node, + metrics: [...node.metrics, ...logRateNode.metrics], + } + : node; + }); + nodes = { + ...nodesWithoutLogsMetrics, + nodes: [...mergedNodes, ...logRateNodes.nodes], + }; + } + } else { + nodes = await transformAndQueryData(client, snapshotRequest, source); + } + + return nodes; +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts index 8804121fc4167..128137efa272e 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts @@ -12,13 +12,14 @@ import { InfraSource } from '../../../lib/sources'; import { createTimeRangeWithInterval } from './create_timerange_with_interval'; import { parseFilterQuery } from '../../../utils/serialized_query'; import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapshot_metrics_to_metrics_api_metrics'; -import { calculateIndexPatterBasedOnMetrics } from './calculate_index_pattern_based_on_metrics'; import { META_KEY } from './constants'; +import { SourceOverrides } from './get_nodes'; export const transformRequestToMetricsAPIRequest = async ( client: ESSearchClient, source: InfraSource, - snapshotRequest: SnapshotRequest + snapshotRequest: SnapshotRequest, + sourceOverrides?: SourceOverrides ): Promise => { const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, { ...snapshotRequest, @@ -27,9 +28,9 @@ export const transformRequestToMetricsAPIRequest = async ( }); const metricsApiRequest: MetricsAPIRequest = { - indexPattern: calculateIndexPatterBasedOnMetrics(snapshotRequest, source), + indexPattern: sourceOverrides?.indexPattern ?? source.configuration.metricAlias, timerange: { - field: source.configuration.fields.timestamp, + field: sourceOverrides?.timestamp ?? source.configuration.fields.timestamp, from: timeRangeWithIntervalApplied.from, to: timeRangeWithIntervalApplied.to, interval: timeRangeWithIntervalApplied.interval, @@ -74,7 +75,7 @@ export const transformRequestToMetricsAPIRequest = async ( top_hits: { size: 1, _source: [inventoryFields.name], - sort: [{ [source.configuration.fields.timestamp]: 'desc' }], + sort: [{ [sourceOverrides?.timestamp ?? source.configuration.fields.timestamp]: 'desc' }], }, }, }, diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts index 190464ab6d5c1..32bb0596ab561 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts @@ -98,6 +98,7 @@ export const logEntriesSearchStrategyProvider = ({ map( ([{ configuration }, messageFormattingRules]): IEsSearchRequest => { return { + // @ts-expect-error @elastic/elasticsearch declares indices_boost as Record params: createGetLogEntriesQuery( configuration.logAlias, params.startTimestamp, @@ -203,13 +204,13 @@ const getLogEntryFromHit = ( } else if ('messageColumn' in column) { return { columnId: column.messageColumn.id, - message: messageFormattingRules.format(hit.fields, hit.highlight || {}), + message: messageFormattingRules.format(hit.fields ?? {}, hit.highlight || {}), }; } else { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: hit.fields[column.fieldColumn.field] ?? [], + value: hit.fields?.[column.fieldColumn.field] ?? [], highlights: hit.highlight?.[column.fieldColumn.field] ?? [], }; } @@ -233,9 +234,9 @@ const pickRequestCursor = ( const getContextFromHit = (hit: LogEntryHit): LogEntryContext => { // Get all context fields, then test for the presence and type of the ones that go together - const containerId = hit.fields['container.id']?.[0]; - const hostName = hit.fields['host.name']?.[0]; - const logFilePath = hit.fields['log.file.path']?.[0]; + const containerId = hit.fields?.['container.id']?.[0]; + const hostName = hit.fields?.['host.name']?.[0]; + const logFilePath = hit.fields?.['log.file.path']?.[0]; if (typeof containerId === 'string') { return { 'container.id': containerId }; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts index 2088761800cfe..b6073f1bbe4c9 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts @@ -65,6 +65,7 @@ export const logEntrySearchStrategyProvider = ({ sourceConfiguration$.pipe( map( ({ configuration }): IEsSearchRequest => ({ + // @ts-expect-error @elastic/elasticsearch declares indices_boost as Record params: createGetLogEntryQuery( configuration.logAlias, params.logEntryId, @@ -121,5 +122,5 @@ const createLogEntryFromHit = (hit: LogEntryHit) => ({ id: hit._id, index: hit._index, cursor: getLogEntryCursorFromHit(hit), - fields: Object.entries(hit.fields).map(([field, value]) => ({ field, value })), + fields: Object.entries(hit.fields ?? {}).map(([field, value]) => ({ field, value })), }); diff --git a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts index b06752ee0a80d..c16d65a75b3e0 100644 --- a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts +++ b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts @@ -45,6 +45,37 @@ export const getGenericRules = (genericMessageFields: string[]) => [ ]; const createGenericRulesForField = (fieldName: string) => [ + { + when: { + exists: ['event.dataset', 'log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'event.dataset', + }, + { + constant: '][', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['event.dataset', 'log.level', fieldName], @@ -70,6 +101,31 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: ['log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['log.level', fieldName], @@ -89,6 +145,22 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: [fieldName, 'error.stack_trace.text'], + }, + format: [ + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: [fieldName], diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts index 460703b22766f..6ae7232d77a17 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RequestParams } from '@elastic/elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import * as rt from 'io-ts'; import { LogEntryAfterCursor, @@ -31,7 +31,7 @@ export const createGetLogEntriesQuery = ( fields: string[], query?: JsonObject, highlightTerm?: string -): RequestParams.AsyncSearchSubmit> => { +): estypes.AsyncSearchSubmitRequest => { const sortDirection = getSortDirection(cursor); const highlightQuery = createHighlightQuery(highlightTerm, fields); @@ -51,6 +51,7 @@ export const createGetLogEntriesQuery = ( ], }, }, + // @ts-expect-error @elastic/elasticsearch doesn't declare body.fields on AsyncSearchSubmitRequest fields, _source: false, ...createSortClause(sortDirection, timestampField, tiebreakerField), @@ -120,10 +121,10 @@ const createHighlightQuery = ( export const logEntryHitRT = rt.intersection([ commonHitFieldsRT, rt.type({ - fields: rt.record(rt.string, jsonArrayRT), sort: rt.tuple([rt.number, rt.number]), }), rt.partial({ + fields: rt.record(rt.string, jsonArrayRT), highlight: rt.record(rt.string, rt.array(rt.string)), }), ]); diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 74a12f14adcaa..85af8b92fe080 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RequestParams } from '@elastic/elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import * as rt from 'io-ts'; import { jsonArrayRT } from '../../../../common/typed_json'; import { @@ -18,7 +18,7 @@ export const createGetLogEntryQuery = ( logEntryId: string, timestampField: string, tiebreakerField: string -): RequestParams.AsyncSearchSubmit> => ({ +): estypes.AsyncSearchSubmitRequest => ({ index: logEntryIndex, terminate_after: 1, track_scores: false, @@ -30,6 +30,7 @@ export const createGetLogEntryQuery = ( values: [logEntryId], }, }, + // @ts-expect-error @elastic/elasticsearch doesn't declare body.fields on AsyncSearchSubmitRequest fields: ['*'], sort: [{ [timestampField]: 'desc' }, { [tiebreakerField]: 'desc' }], _source: false, @@ -39,9 +40,11 @@ export const createGetLogEntryQuery = ( export const logEntryHitRT = rt.intersection([ commonHitFieldsRT, rt.type({ - fields: rt.record(rt.string, jsonArrayRT), sort: rt.tuple([rt.number, rt.number]), }), + rt.partial({ + fields: rt.record(rt.string, jsonArrayRT), + }), ]); export type LogEntryHit = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts b/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts new file mode 100644 index 0000000000000..9497a8b442768 --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_queries/get_log_query_fields.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { InfraSources } from '../../lib/sources'; + +// NOTE: TEMPORARY: This will become a subset of the new resolved KIP compatible log source configuration. +export interface LogQueryFields { + indexPattern: string; + timestamp: string; +} + +// NOTE: TEMPORARY: This will become a subset of the new resolved KIP compatible log source configuration. +export const createGetLogQueryFields = (sources: InfraSources) => { + return async ( + sourceId: string, + savedObjectsClient: SavedObjectsClientContract + ): Promise => { + const source = await sources.getSourceConfiguration(savedObjectsClient, sourceId); + + return { + indexPattern: source.configuration.logAlias, + timestamp: source.configuration.fields.timestamp, + }; + }; +}; + +export type GetLogQueryFields = ReturnType; diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index 1c51a5549cb41..5cae015861946 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -5,7 +5,8 @@ * 2.0. */ -import type { DataRequestHandlerContext } from '../../../../src/plugins/data/server'; +import type { RequestHandlerContext } from 'src/core/server'; +import type { SearchRequestHandlerContext } from '../../../../src/plugins/data/server'; import { MlPluginSetup } from '../../ml/server'; export type MlSystem = ReturnType; @@ -26,6 +27,7 @@ export type InfraRequestHandlerContext = InfraMlRequestHandlerContext & /** * @internal */ -export interface InfraPluginRequestHandlerContext extends DataRequestHandlerContext { +export interface InfraPluginRequestHandlerContext extends RequestHandlerContext { infra: InfraRequestHandlerContext; + search: SearchRequestHandlerContext; } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx new file mode 100644 index 0000000000000..c6449dbd7a93e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the Bytes processor when saved +const defaultBytesParameters = { + ignore_failure: undefined, + description: undefined, +}; + +const BYTES_TYPE = 'bytes'; + +describe('Processor: Bytes', () => { + 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(); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + + // 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 values', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + field: 'field_1', + ...defaultBytesParameters, + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { addProcessor, addProcessorType, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('targetField.input', 'target_field'); + + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + description: undefined, + field: 'field_1', + ignore_failure: undefined, + target_field: 'target_field', + ignore_missing: true, + tag: undefined, + if: undefined, + }); + }); +}); 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 c08627de636d7..8340cf45b1f1b 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 @@ -90,9 +90,9 @@ const createActions = (testBed: TestBed) => { component.update(); }, - async addProcessorType({ type, label }: { type: string; label: string }) { + async addProcessorType(type: string) { await act(async () => { - find('processorTypeSelector.input').simulate('change', [{ value: type, label }]); + find('processorTypeSelector.input').simulate('change', [{ value: type }]); }); component.update(); }, @@ -127,12 +127,19 @@ export const setupEnvironment = () => { }; }; +export const getProcessorValue = (onUpdate: jest.Mock, type: string) => { + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + return processors; +}; + type TestSubject = | 'addProcessorForm.submitButton' | 'addProcessorButton' | 'addProcessorForm.submitButton' | 'processorTypeSelector.input' | 'fieldNameField.input' + | 'ignoreMissingSwitch.input' | 'targetField.input' | 'keepOriginalField.input' | 'removeIfSuccessfulField.input'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx new file mode 100644 index 0000000000000..de0061dcb0407 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult } from './processor.helpers'; + +describe('Processor: Bytes', () => { + 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(); + }); + + test('Prevents form submission if processor type not selected', async () => { + const { + actions: { addProcessor, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx index 41078b7e96df9..573adad3247f5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx @@ -6,7 +6,7 @@ */ import { act } from 'react-dom/test-utils'; -import { setup, SetupResult } from './processor.helpers'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; // Default parameter values automatically added to the URI parts processor when saved const defaultUriPartsParameters = { @@ -16,6 +16,8 @@ const defaultUriPartsParameters = { description: undefined, }; +const URI_PARTS_TYPE = 'uri_parts'; + describe('Processor: URI parts', () => { let onUpdate: jest.Mock; let testBed: SetupResult; @@ -51,14 +53,9 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); - // Click submit button without entering any fields - await saveNewProcessor(); - - // Expect form error as a processor type is required - expect(form.getErrorsMessages()).toEqual(['A type is required.']); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Click submit button with only the type defined await saveNewProcessor(); @@ -76,14 +73,13 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); // Save the field await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ field: 'field_1', ...defaultUriPartsParameters, @@ -99,7 +95,7 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); @@ -111,8 +107,7 @@ describe('Processor: URI parts', () => { // Save the field with new changes await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ description: undefined, field: 'field_1', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx index 82e086102b488..744e9798c4fb0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx @@ -50,5 +50,6 @@ export const IgnoreMissingField: FunctionComponent = (props) => ( config={{ ...fieldsConfig.ignore_missing, ...props }} component={ToggleField} path="fields.ignore_missing" + data-test-subj="ignoreMissingSwitch" /> ); diff --git a/x-pack/plugins/lens/jest.config.js b/x-pack/plugins/lens/jest.config.js index 615e540eaedce..9a3f12e1ead32 100644 --- a/x-pack/plugins/lens/jest.config.js +++ b/x-pack/plugins/lens/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/lens'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95202 + testEnvironment: 'jest-environment-jsdom-thirteen', }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index 3d499b7b7b45a..e171c457c541e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -22,6 +22,22 @@ import { generateId } from '../../../id_generator'; jest.mock('../../../id_generator'); +let container: HTMLDivElement | undefined; + +beforeEach(() => { + container = document.createElement('div'); + container.id = 'lensContainer'; + document.body.appendChild(container); +}); + +afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + + container = undefined; +}); + describe('ConfigPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; @@ -105,7 +121,9 @@ describe('ConfigPanel', () => { describe('focus behavior when adding or removing layers', () => { it('should focus the only layer when resetting the layer', () => { - const component = mountWithIntl(); + const component = mountWithIntl(, { + attachTo: container, + }); const firstLayerFocusable = component .find(LayerPanel) .first() @@ -126,7 +144,7 @@ describe('ConfigPanel', () => { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(); + const component = mountWithIntl(, { attachTo: container }); const secondLayerFocusable = component .find(LayerPanel) .at(1) @@ -147,7 +165,7 @@ describe('ConfigPanel', () => { first: mockDatasource.publicAPIMock, second: mockDatasource.publicAPIMock, }; - const component = mountWithIntl(); + const component = mountWithIntl(, { attachTo: container }); const firstLayerFocusable = component .find(LayerPanel) .first() @@ -169,7 +187,9 @@ describe('ConfigPanel', () => { } }); - const component = mountWithIntl(); + const component = mountWithIntl(, { + attachTo: container, + }); act(() => { component.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 30740bbd6b217..2c850d0b4472c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -7,22 +7,38 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { EuiFormRow } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { Visualization } from '../../../types'; +import { LayerPanel } from './layer_panel'; +import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { generateId } from '../../../id_generator'; import { createMockVisualization, createMockFramePublicAPI, createMockDatasource, DatasourceMock, } from '../../mocks'; -import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; -import { EuiFormRow } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test/jest'; -import { Visualization } from '../../../types'; -import { LayerPanel } from './layer_panel'; -import { coreMock } from 'src/core/public/mocks'; -import { generateId } from '../../../id_generator'; jest.mock('../../../id_generator'); +let container: HTMLDivElement | undefined; + +beforeEach(() => { + container = document.createElement('div'); + container.id = 'lensContainer'; + document.body.appendChild(container); +}); + +afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + + container = undefined; +}); + const defaultContext = { dragging: undefined, setDragging: jest.fn(), @@ -642,7 +658,8 @@ describe('LayerPanel', () => { const component = mountWithIntl( - + , + { attachTo: container } ); act(() => { component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index fef8ee171830d..669758c5193a6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -173,7 +173,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }, @@ -200,7 +200,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 68a3e9056b05d..2cad77b003454 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -496,6 +496,15 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ }; }, []); + const refreshFieldList = useCallback(async () => { + const newlyMappedIndexPattern = await loadIndexPatterns({ + indexPatternsService: data.indexPatterns, + cache: {}, + patterns: [currentIndexPattern.id], + }); + onUpdateIndexPattern(newlyMappedIndexPattern[currentIndexPattern.id]); + }, [data, currentIndexPattern, onUpdateIndexPattern]); + const editField = useMemo( () => editPermission @@ -509,17 +518,39 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ fieldName, onSave: async () => { trackUiEvent(`save_field_${uiAction}`); - const newlyMappedIndexPattern = await loadIndexPatterns({ - indexPatternsService: data.indexPatterns, - cache: {}, - patterns: [currentIndexPattern.id], - }); - onUpdateIndexPattern(newlyMappedIndexPattern[currentIndexPattern.id]); + await refreshFieldList(); + }, + }); + } + : undefined, + [data, indexPatternFieldEditor, currentIndexPattern, editPermission, refreshFieldList] + ); + + const removeField = useMemo( + () => + editPermission + ? async (fieldName: string) => { + trackUiEvent('open_field_delete_modal'); + const indexPatternInstance = await data.indexPatterns.get(currentIndexPattern.id); + closeFieldEditor.current = indexPatternFieldEditor.openDeleteModal({ + ctx: { + indexPattern: indexPatternInstance, + }, + fieldName, + onDelete: async () => { + trackUiEvent('delete_field'); + await refreshFieldList(); }, }); } : undefined, - [data, indexPatternFieldEditor, currentIndexPattern, editPermission, onUpdateIndexPattern] + [ + currentIndexPattern.id, + data.indexPatterns, + editPermission, + indexPatternFieldEditor, + refreshFieldList, + ] ); const addField = useMemo( @@ -765,6 +796,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} editField={editField} + removeField={removeField} />
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx new file mode 100644 index 0000000000000..ea5eb14d9c20e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiLink, EuiText, EuiPopover, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +export function AdvancedOptions(props: { + options: Array<{ + title: string; + dataTestSubj: string; + onClick: () => void; + showInPopover: boolean; + inlineElement: React.ReactElement | null; + }>; +}) { + const [popoverOpen, setPopoverOpen] = useState(false); + const popoverOptions = props.options.filter((option) => option.showInPopover); + const inlineOptions = props.options + .filter((option) => option.inlineElement) + .map((option) => React.cloneElement(option.inlineElement!, { key: option.dataTestSubj })); + + return ( + <> + {popoverOptions.length > 0 && ( + + + { + setPopoverOpen(!popoverOpen); + }} + > + {i18n.translate('xpack.lens.indexPattern.advancedSettings', { + defaultMessage: 'Add advanced options', + })} + + } + isOpen={popoverOpen} + closePopover={() => { + setPopoverOpen(false); + }} + > + {popoverOptions.map(({ dataTestSubj, onClick, title }, index) => ( + + + { + setPopoverOpen(false); + onClick(); + }} + > + {title} + + + {popoverOptions.length - 1 !== index && } + + ))} + + + )} + {inlineOptions.length > 0 && ( + <> + + {inlineOptions} + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx index ccdb86d250962..c6ecdd73cb6ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.test.tsx @@ -134,7 +134,7 @@ describe('BucketNestingEditor', () => { layer={{ columnOrder: ['a', 'b', 'c'], columns: { - a: mockCol({ operationType: 'avg', isBucketed: false }), + a: mockCol({ operationType: 'average', isBucketed: false }), b: mockCol({ operationType: 'max', isBucketed: false }), c: mockCol({ operationType: 'min', isBucketed: false }), }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 08842fb755888..1fc755ec489c7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -32,6 +32,7 @@ import { resetIncomplete, FieldBasedIndexPatternColumn, canTransition, + DEFAULT_TIME_SCALE, } from '../operations'; import { mergeLayer } from '../state_helpers'; import { FieldSelect } from './field_select'; @@ -41,7 +42,9 @@ import { IndexPattern, IndexPatternLayer } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { FormatSelector } from './format_selector'; import { ReferenceEditor } from './reference_editor'; -import { TimeScaling } from './time_scaling'; +import { setTimeScaling, TimeScaling } from './time_scaling'; +import { defaultFilter, Filtering, setFilter } from './filtering'; +import { AdvancedOptions } from './advanced_options'; const operationPanels = getOperationDisplay(); @@ -156,6 +159,8 @@ export function DimensionEditor(props: DimensionEditorProps) { .filter((type) => fieldByOperation[type]?.size || operationWithoutField.has(type)); }, [fieldByOperation, operationWithoutField]); + const [filterByOpenInitially, setFilterByOpenInitally] = useState(false); + // Operations are compatible if they match inputs. They are always compatible in // the empty state. Field-based operations are not compatible with field-less operations. const operationsWithCompatibility = [...possibleOperations].map((operationType) => { @@ -458,11 +463,63 @@ export function DimensionEditor(props: DimensionEditorProps) { )} {!currentFieldIsInvalid && !incompleteInfo && selectedColumn && ( - { + setStateWrapper( + setTimeScaling(columnId, state.layers[layerId], DEFAULT_TIME_SCALE) + ); + }, + showInPopover: Boolean( + operationDefinitionMap[selectedColumn.operationType].timeScalingMode && + operationDefinitionMap[selectedColumn.operationType].timeScalingMode !== + 'disabled' && + Object.values(state.layers[layerId].columns).some( + (col) => col.operationType === 'date_histogram' + ) && + !selectedColumn.timeScale + ), + inlineElement: ( + + ), + }, + { + title: i18n.translate('xpack.lens.indexPattern.filterBy.label', { + defaultMessage: 'Filter by', + }), + dataTestSubj: 'indexPattern-filter-by-enable', + onClick: () => { + setFilterByOpenInitally(true); + setStateWrapper(setFilter(columnId, state.layers[layerId], defaultFilter)); + }, + showInPopover: Boolean( + operationDefinitionMap[selectedColumn.operationType].filterable && + !selectedColumn.filter + ), + inlineElement: + operationDefinitionMap[selectedColumn.operationType].filterable && + selectedColumn.filter ? ( + + ) : null, + }, + ]} /> )}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index a6d2361be21d4..7d1644d07d2aa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -6,7 +6,7 @@ */ import { ReactWrapper, ShallowWrapper } from 'enzyme'; -import React, { ChangeEvent, MouseEvent } from 'react'; +import React, { ChangeEvent, MouseEvent, ReactElement } from 'react'; import { act } from 'react-dom/test-utils'; import { EuiComboBox, @@ -15,6 +15,7 @@ import { EuiRange, EuiSelect, EuiButtonIcon, + EuiPopover, } from '@elastic/eui'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { @@ -30,10 +31,14 @@ import { documentField } from '../document_field'; import { OperationMetadata } from '../../types'; import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram'; import { getFieldByNameFactory } from '../pure_helpers'; -import { TimeScaling } from './time_scaling'; import { DimensionEditor } from './dimension_editor'; +import { AdvancedOptions } from './advanced_options'; +import { Filtering } from './filtering'; jest.mock('../loader'); +jest.mock('../query_input', () => ({ + QueryInput: () => null, +})); jest.mock('../operations'); jest.mock('lodash', () => { const original = jest.requireActual('lodash'); @@ -367,7 +372,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Unique count of source', dataType: 'number', isBucketed: false, - operationType: 'cardinality', + operationType: 'unique_count', sourceField: 'source,', }, })} @@ -409,7 +414,7 @@ describe('IndexPatternDimensionEditorPanel', () => { const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).toContain( + expect(items.find(({ id }) => id === 'differences')!['data-test-subj']).toContain( 'incompatible' ); expect(items.find(({ id }) => id === 'cumulative_sum')!['data-test-subj']).toContain( @@ -457,7 +462,7 @@ describe('IndexPatternDimensionEditorPanel', () => { 'incompatible' ); - expect(items.find(({ id }) => id === 'derivative')!['data-test-subj']).not.toContain( + expect(items.find(({ id }) => id === 'differences')!['data-test-subj']).not.toContain( 'incompatible' ); expect(items.find(({ id }) => id === 'moving_average')!['data-test-subj']).not.toContain( @@ -812,7 +817,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('should select compatible operation if field not compatible with selected operation', () => { wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { ...state, @@ -820,7 +825,7 @@ describe('IndexPatternDimensionEditorPanel', () => { first: { ...state.layers.first, incompleteColumns: { - col2: { operationType: 'avg' }, + col2: { operationType: 'average' }, }, }, }, @@ -833,7 +838,7 @@ describe('IndexPatternDimensionEditorPanel', () => { .filter('[data-test-subj="indexPattern-dimension-field"]'); const options = comboBox.prop('options'); - // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation + // options[1][2] is a `source` field of type `string` which doesn't support `average` operation act(() => { comboBox.prop('onChange')!([options![1].options![2]]); }); @@ -880,7 +885,7 @@ describe('IndexPatternDimensionEditorPanel', () => { // Transition to a field operation (incompatible) wrapper - .find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-average incompatible"]') .simulate('click'); // Now check that the dimension gets cleaned up on state update @@ -891,7 +896,7 @@ describe('IndexPatternDimensionEditorPanel', () => { first: { ...state.layers.first, incompleteColumns: { - col2: { operationType: 'avg' }, + col2: { operationType: 'average' }, }, }, }, @@ -909,7 +914,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, })} @@ -1029,25 +1034,34 @@ describe('IndexPatternDimensionEditorPanel', () => { } it('should not show custom options if time scaling is not available', () => { - wrapper = mount( + wrapper = shallow( ); - expect(wrapper.find('[data-test-subj="indexPattern-time-scaling"]')).toHaveLength(0); + expect( + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-time-scaling-enable"]') + ).toHaveLength(0); }); it('should show custom options if time scaling is available', () => { - wrapper = mount(); + wrapper = shallow(); expect( wrapper - .find(TimeScaling) - .find('[data-test-subj="indexPattern-time-scaling-popover"]') - .exists() - ).toBe(true); + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-time-scaling-enable"]') + ).toHaveLength(1); }); it('should show current time scaling if set', () => { @@ -1066,7 +1080,7 @@ describe('IndexPatternDimensionEditorPanel', () => { wrapper .find(DimensionEditor) .dive() - .find(TimeScaling) + .find(AdvancedOptions) .dive() .find('[data-test-subj="indexPattern-time-scaling-enable"]') .prop('onClick')!({} as MouseEvent); @@ -1129,7 +1143,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Sum of bytes per hour', }); wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(props.setState).toHaveBeenCalledWith( { ...props.state, @@ -1239,6 +1253,199 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + describe('filtering', () => { + function getProps(colOverrides: Partial) { + return { + ...defaultProps, + state: getStateWithColumns({ + datecolumn: { + dataType: 'date', + isBucketed: true, + label: '', + customLabel: true, + operationType: 'date_histogram', + sourceField: 'ts', + params: { + interval: '1d', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + sourceField: 'Records', + ...colOverrides, + } as IndexPatternColumn, + }), + columnId: 'col2', + }; + } + + it('should not show custom options if time scaling is not available', () => { + wrapper = shallow( + + ); + expect( + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-filter-by-enable"]') + ).toHaveLength(0); + }); + + it('should show custom options if filtering is available', () => { + wrapper = shallow(); + expect( + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-filter-by-enable"]') + ).toHaveLength(1); + }); + + it('should show current filter if set', () => { + wrapper = mount( + + ); + expect( + (wrapper.find(Filtering).find(EuiPopover).prop('children') as ReactElement).props.value + ).toEqual({ language: 'kuery', query: 'a: b' }); + }); + + it('should allow to set filter initially', () => { + const props = getProps({}); + wrapper = shallow(); + wrapper + .find(DimensionEditor) + .dive() + .find(AdvancedOptions) + .dive() + .find('[data-test-subj="indexPattern-filter-by-enable"]') + .prop('onClick')!({} as MouseEvent); + expect(props.setState).toHaveBeenCalledWith( + { + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + filter: { + language: 'kuery', + query: '', + }, + }), + }, + }, + }, + }, + { shouldRemoveDimension: false, shouldReplaceDimension: true } + ); + }); + + it('should carry over filter to other operation if possible', () => { + const props = getProps({ + filter: { language: 'kuery', query: 'a: b' }, + sourceField: 'bytes', + operationType: 'sum', + label: 'Sum of bytes per hour', + }); + wrapper = mount(); + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]') + .simulate('click'); + expect(props.setState).toHaveBeenCalledWith( + { + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + filter: { language: 'kuery', query: 'a: b' }, + }), + }, + }, + }, + }, + { shouldRemoveDimension: false, shouldReplaceDimension: true } + ); + }); + + it('should allow to change filter', () => { + const props = getProps({ + filter: { language: 'kuery', query: 'a: b' }, + }); + wrapper = mount(); + (wrapper.find(Filtering).find(EuiPopover).prop('children') as ReactElement).props.onChange({ + language: 'kuery', + query: 'c: d', + }); + expect(props.setState).toHaveBeenCalledWith( + { + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + filter: { language: 'kuery', query: 'c: d' }, + }), + }, + }, + }, + }, + { shouldRemoveDimension: false, shouldReplaceDimension: true } + ); + }); + + it('should allow to remove filter', () => { + const props = getProps({ + filter: { language: 'kuery', query: 'a: b' }, + }); + wrapper = mount(); + wrapper + .find('[data-test-subj="indexPattern-filter-by-remove"]') + .find(EuiButtonIcon) + .prop('onClick')!( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {} as any + ); + expect(props.setState).toHaveBeenCalledWith( + { + ...props.state, + layers: { + first: { + ...props.state.layers.first, + columns: { + ...props.state.layers.first.columns, + col2: expect.objectContaining({ + filter: undefined, + }), + }, + }, + }, + }, + { shouldRemoveDimension: false, shouldReplaceDimension: true } + ); + }); + }); + it('should render invalid field if field reference is broken', () => { wrapper = mount( { it('should support selecting the operation before the field', () => { wrapper = mount(); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { @@ -1281,7 +1488,7 @@ describe('IndexPatternDimensionEditorPanel', () => { ...state.layers.first, incompleteColumns: { col2: { - operationType: 'avg', + operationType: 'average', }, }, }, @@ -1309,7 +1516,7 @@ describe('IndexPatternDimensionEditorPanel', () => { columns: { ...state.layers.first.columns, col2: expect.objectContaining({ - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }), }, @@ -1340,7 +1547,7 @@ describe('IndexPatternDimensionEditorPanel', () => { /> ); - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); expect(setState).toHaveBeenCalledWith( { @@ -1352,7 +1559,7 @@ describe('IndexPatternDimensionEditorPanel', () => { ...initialState.layers.first.columns, col2: expect.objectContaining({ sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', // Other parts of this don't matter for this test }), }, @@ -1394,7 +1601,7 @@ describe('IndexPatternDimensionEditorPanel', () => { wrapper = mount(); act(() => { - wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + wrapper.find('button[data-test-subj="lns-indexPatternDimension-average"]').simulate('click'); }); const options = wrapper @@ -1583,7 +1790,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }); @@ -1624,7 +1831,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', params: { format: { id: 'bytes', params: { decimals: 0 } }, @@ -1664,7 +1871,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'memory', params: { format: { id: 'bytes', params: { decimals: 2 } }, @@ -1707,7 +1914,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect(wrapper.find('ReferenceEditor')).toHaveLength(0); wrapper - .find('button[data-test-subj="lns-indexPatternDimension-derivative incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-differences incompatible"]') .simulate('click'); expect(wrapper.find('ReferenceEditor')).toHaveLength(1); @@ -1719,7 +1926,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Differences of (incomplete)', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['col2'], params: {}, }, @@ -1732,7 +1939,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect(wrapper.find('ReferenceEditor')).toHaveLength(1); wrapper - .find('button[data-test-subj="lns-indexPatternDimension-avg incompatible"]') + .find('button[data-test-subj="lns-indexPatternDimension-average incompatible"]') .simulate('click'); expect(wrapper.find('ReferenceEditor')).toHaveLength(0); @@ -1741,10 +1948,10 @@ describe('IndexPatternDimensionEditorPanel', () => { it('should show a warning when the current dimension is no longer configurable', () => { const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({ col1: { - label: 'Invalid derivative', + label: 'Invalid differences', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['ref1'], }, }); @@ -1755,7 +1962,7 @@ describe('IndexPatternDimensionEditorPanel', () => { expect( wrapper - .find('[data-test-subj="lns-indexPatternDimension-derivative incompatible"]') + .find('[data-test-subj="lns-indexPatternDimension-differences incompatible"]') .find('EuiText[color="danger"]') .first() ).toBeTruthy(); @@ -1768,7 +1975,7 @@ describe('IndexPatternDimensionEditorPanel', () => { label: 'Avg', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }), @@ -1803,6 +2010,8 @@ describe('IndexPatternDimensionEditorPanel', () => { ); - expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-derivative"]')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="lns-indexPatternDimension-differences"]')).toHaveLength( + 0 + ); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index 82b6434e50aac..4f73454b68811 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -1035,7 +1035,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }, col3: { - operationType: 'avg', + operationType: 'average', sourceField: 'memory', label: 'average of memory', dataType: 'number', 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 new file mode 100644 index 0000000000000..ae7406e42746a --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiLink, EuiPanel, EuiPopover } from '@elastic/eui'; +import { EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { Query } from 'src/plugins/data/public'; +import { IndexPatternColumn, operationDefinitionMap } from '../operations'; +import { isQueryValid } from '../operations/definitions/filters'; +import { QueryInput } from '../query_input'; +import { IndexPattern, IndexPatternLayer } from '../types'; + +// to do: get the language from uiSettings +export const defaultFilter: Query = { + query: '', + language: 'kuery', +}; + +export function setFilter(columnId: string, layer: IndexPatternLayer, query: Query | undefined) { + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...layer.columns[columnId], + filter: query, + }, + }, + }; +} + +export function Filtering({ + selectedColumn, + columnId, + layer, + updateLayer, + indexPattern, + isInitiallyOpen, +}: { + selectedColumn: IndexPatternColumn; + indexPattern: IndexPattern; + columnId: string; + layer: IndexPatternLayer; + updateLayer: (newLayer: IndexPatternLayer) => void; + isInitiallyOpen: boolean; +}) { + const [filterPopoverOpen, setFilterPopoverOpen] = useState(isInitiallyOpen); + const selectedOperation = operationDefinitionMap[selectedColumn.operationType]; + if (!selectedOperation.filterable || !selectedColumn.filter) { + return null; + } + + const isInvalid = !isQueryValid(selectedColumn.filter, indexPattern); + + return ( + + + + { + setFilterPopoverOpen(false); + }} + anchorClassName="eui-fullWidth" + panelClassName="lnsIndexPatternDimensionEditor__filtersEditor" + button={ + + + {/* Empty for spacing */} + + { + setFilterPopoverOpen(!filterPopoverOpen); + }} + color={isInvalid ? 'danger' : 'text'} + title={i18n.translate('xpack.lens.indexPattern.filterBy.clickToEdit', { + defaultMessage: 'Click to edit', + })} + > + {selectedColumn.filter.query || + i18n.translate('xpack.lens.indexPattern.filterBy.emptyFilterQuery', { + defaultMessage: '(empty)', + })} + + + + + } + > + { + updateLayer(setFilter(columnId, layer, newQuery)); + }} + isInvalid={false} + onSubmit={() => {}} + /> + + + + { + updateLayer(setFilter(columnId, layer, undefined)); + }} + iconType="cross" + /> + + + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts index d462d73740aaa..801b1b17a1831 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/operation_support.ts @@ -8,7 +8,7 @@ import _ from 'lodash'; import { DatasourceDimensionDropProps } from '../../types'; import { OperationType } from '../indexpattern'; -import { getAvailableOperationsByMetadata } from '../operations'; +import { memoizedGetAvailableOperationsByMetadata } from '../operations'; import { IndexPatternPrivateState } from '../types'; export interface OperationSupportMatrix { @@ -30,7 +30,7 @@ export const getOperationSupportMatrix = (props: Props): OperationSupportMatrix const layerId = props.layerId; const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; - const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + const filteredOperationsByMetadata = memoizedGetAvailableOperationsByMetadata( currentIndexPattern ).filter((operation) => props.filterOperations(operation.operationMetaData)); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx index 9ad6a2d20a4c2..f17adf9be39f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/reference_editor.test.tsx @@ -157,7 +157,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -192,7 +192,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -233,7 +233,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -276,7 +276,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -296,9 +296,9 @@ describe('reference editor', () => { expect(subFunctionSelect.prop('selectedOptions')).toEqual( expect.arrayContaining([ expect.objectContaining({ - 'data-test-subj': 'lns-indexPatternDimension-avg incompatible', + 'data-test-subj': 'lns-indexPatternDimension-average incompatible', label: 'Average', - value: 'avg', + value: 'average', }), ]) ); @@ -334,7 +334,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -352,7 +352,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true); expect(fieldSelect.prop('selectedField')).toEqual('bytes'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toEqual('max'); expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false); }); @@ -369,7 +369,7 @@ describe('reference editor', () => { label: 'Average of bytes', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -387,7 +387,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(false); expect(fieldSelect.prop('selectedField')).toEqual('timestamp'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toBeUndefined(); }); @@ -423,7 +423,7 @@ describe('reference editor', () => { label: 'Average of missing', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'missing', }, }, @@ -438,7 +438,7 @@ describe('reference editor', () => { const fieldSelect = wrapper.find(FieldSelect); expect(fieldSelect.prop('fieldIsInvalid')).toEqual(true); expect(fieldSelect.prop('selectedField')).toEqual('missing'); - expect(fieldSelect.prop('selectedOperationType')).toEqual('avg'); + expect(fieldSelect.prop('selectedOperationType')).toEqual('average'); expect(fieldSelect.prop('incompleteOperation')).toBeUndefined(); expect(fieldSelect.prop('markAllFieldsCompatible')).toEqual(false); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx index a9362060b2dd0..bf5b64bf3d615 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx @@ -7,23 +7,11 @@ import { EuiToolTip } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; -import { - EuiLink, - EuiFormRow, - EuiSelect, - EuiFlexItem, - EuiFlexGroup, - EuiButtonIcon, - EuiText, - EuiPopover, - EuiButtonEmpty, - EuiSpacer, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; +import React from 'react'; import { adjustTimeScaleLabelSuffix, - DEFAULT_TIME_SCALE, IndexPatternColumn, operationDefinitionMap, } from '../operations'; @@ -64,7 +52,6 @@ export function TimeScaling({ layer: IndexPatternLayer; updateLayer: (newLayer: IndexPatternLayer) => void; }) { - const [popoverOpen, setPopoverOpen] = useState(false); const hasDateHistogram = layer.columnOrder.some( (colId) => layer.columns[colId].operationType === 'date_histogram' ); @@ -72,56 +59,12 @@ export function TimeScaling({ if ( !selectedOperation.timeScalingMode || selectedOperation.timeScalingMode === 'disabled' || - !hasDateHistogram + !hasDateHistogram || + !selectedColumn.timeScale ) { return null; } - if (!selectedColumn.timeScale) { - return ( - - - { - setPopoverOpen(!popoverOpen); - }} - > - {i18n.translate('xpack.lens.indexPattern.timeScale.advancedSettings', { - defaultMessage: 'Add advanced options', - })} - - } - isOpen={popoverOpen} - closePopover={() => { - setPopoverOpen(false); - }} - > - - { - setPopoverOpen(false); - updateLayer(setTimeScaling(columnId, layer, DEFAULT_TIME_SCALE)); - }} - > - {i18n.translate('xpack.lens.indexPattern.timeScale.enableTimeScale', { - defaultMessage: 'Normalize by unit', - })} - - - - - ); - } - return ( void; + removeField?: (name: string) => void; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } @@ -107,6 +108,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { groupIndex, dropOntoWorkspace, editField, + removeField, } = props; const [infoIsOpen, setOpen] = useState(false); @@ -122,6 +124,17 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { [editField, setOpen] ); + const closeAndRemove = useMemo( + () => + removeField + ? (name: string) => { + removeField(name); + setOpen(false); + } + : undefined, + [removeField, setOpen] + ); + const dropOntoWorkspaceAndClose = useCallback( (droppedField: DragDropIdentifier) => { dropOntoWorkspace(droppedField); @@ -270,6 +283,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { {...state} {...props} editField={closeAndEdit} + removeField={closeAndRemove} dropOntoWorkspace={dropOntoWorkspaceAndClose} /> @@ -285,12 +299,14 @@ function FieldPanelHeader({ hasSuggestionForField, dropOntoWorkspace, editField, + removeField, }: { field: IndexPatternField; indexPatternId: string; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; editField?: (name: string) => void; + removeField?: (name: string) => void; }) { const draggableField = { indexPatternId, @@ -302,7 +318,7 @@ function FieldPanelHeader({ }; return ( - +
{field.displayName}
@@ -315,20 +331,41 @@ function FieldPanelHeader({ field={draggableField} /> {editField && ( - - editField(field.name)} - iconType="pencil" - data-test-subj="lnsFieldListPanelEdit" - aria-label={i18n.translate('xpack.lens.indexPattern.editFieldLabel', { + + - + > + editField(field.name)} + iconType="pencil" + data-test-subj="lnsFieldListPanelEdit" + aria-label={i18n.translate('xpack.lens.indexPattern.editFieldLabel', { + defaultMessage: 'Edit index pattern field', + })} + /> + +
+ )} + {removeField && field.runtime && ( + + + removeField(field.name)} + iconType="trash" + data-test-subj="lnsFieldListPanelRemove" + color="danger" + aria-label={i18n.translate('xpack.lens.indexPattern.removeFieldLabel', { + defaultMessage: 'Remove index pattern field', + })} + /> + + )}
); @@ -347,6 +384,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { data: { fieldFormats }, dropOntoWorkspace, editField, + removeField, hasSuggestionForField, hideDetails, } = props; @@ -379,6 +417,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} editField={editField} + removeField={removeField} /> ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx index 01ba0726d9e4d..ceeb1f5b1caf3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx @@ -53,6 +53,7 @@ export const FieldList = React.memo(function FieldList({ dropOntoWorkspace, hasSuggestionForField, editField, + removeField, }: { exists: (field: IndexPatternField) => boolean; fieldGroups: FieldGroups; @@ -68,6 +69,7 @@ export const FieldList = React.memo(function FieldList({ dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; editField?: (name: string) => void; + removeField?: (name: string) => void; }) { const [pageSize, setPageSize] = useState(PAGINATION_SIZE); const [scrollContainer, setScrollContainer] = useState(undefined); @@ -144,6 +146,7 @@ export const FieldList = React.memo(function FieldList({ exists={exists(field)} field={field} editField={editField} + removeField={removeField} hideDetails={true} key={field.name} itemIndex={index} @@ -169,6 +172,7 @@ export const FieldList = React.memo(function FieldList({ helpTooltip={fieldGroup.helpText} exists={exists} editField={editField} + removeField={removeField} hideDetails={fieldGroup.hideDetails} hasLoaded={!!hasSyncedExistingFields} fieldsCount={fieldGroup.fields.length} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 74ea13a81539f..a00f25b04651b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -55,6 +55,7 @@ export interface FieldsAccordionProps { dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; editField?: (name: string) => void; + removeField?: (name: string) => void; } export const FieldsAccordion = memo(function InnerFieldsAccordion({ @@ -76,6 +77,7 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ dropOntoWorkspace, hasSuggestionForField, editField, + removeField, }: FieldsAccordionProps) { const renderField = useCallback( (field: IndexPatternField, index) => ( @@ -90,6 +92,7 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} editField={editField} + removeField={removeField} /> ), [ @@ -100,6 +103,7 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ hasSuggestionForField, groupIndex, editField, + removeField, ] ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/help_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/help_popover.tsx index ca126ff4e40bf..0e4c1897743b4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/help_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/help_popover.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import { EuiIcon, EuiLink, @@ -15,6 +15,7 @@ import { EuiPopoverTitle, EuiText, } from '@elastic/eui'; +import { trackUiEvent } from '../lens_ui_telemetry'; import './help_popover.scss'; export const HelpPopoverButton = ({ @@ -50,6 +51,11 @@ export const HelpPopover = ({ isOpen: EuiPopoverProps['isOpen']; title?: string; }) => { + useEffect(() => { + if (isOpen) { + trackUiEvent('open_help_popover'); + } + }, [isOpen]); return ( { ]); }); + it('should wrap filtered metrics in filtered metric aggregation', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + timeScale: 'h', + filter: { + language: 'kuery', + query: 'bytes > 5', + }, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + operationType: 'average', + timeScale: 'h', + }, + col3: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.aggs[0]).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "customBucket": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "enabled": Array [ + true, + ], + "filter": Array [ + "{\\"language\\":\\"kuery\\",\\"query\\":\\"bytes > 5\\"}", + ], + "id": Array [ + "col1-filter", + ], + "schema": Array [ + "bucket", + ], + }, + "function": "aggFilter", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "customMetric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "enabled": Array [ + true, + ], + "id": Array [ + "col1-metric", + ], + "schema": Array [ + "metric", + ], + }, + "function": "aggCount", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "enabled": Array [ + true, + ], + "id": Array [ + "col1", + ], + "schema": Array [ + "metric", + ], + }, + "function": "aggFilteredMetric", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + it('should add time_scale and format function if time scale is set and supported', async () => { const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', @@ -542,7 +659,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', timeScale: 'h', }, col3: { @@ -718,7 +835,7 @@ describe('IndexPattern Data Source', () => { dataType: 'date', isBucketed: false, sourceField: 'timefield', - operationType: 'cardinality', + operationType: 'unique_count', }, col2: { label: 'Date', @@ -768,7 +885,7 @@ describe('IndexPattern Data Source', () => { dataType: 'date', isBucketed: false, sourceField: 'timefield', - operationType: 'cardinality', + operationType: 'unique_count', }, col2: { label: 'Reference', @@ -1066,7 +1183,7 @@ describe('IndexPattern Data Source', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -1093,7 +1210,7 @@ describe('IndexPattern Data Source', () => { columnOrder: [], columns: {}, incompleteColumns: { - col1: { operationType: 'avg' as const }, + col1: { operationType: 'average' as const }, col2: { operationType: 'sum' as const }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 210716e7494e0..e742b6ba62aff 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -735,7 +735,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'bytes', label: 'Avg of bytes', customLabel: true, - operationType: 'avg', + operationType: 'average', }, }, columnOrder: ['cola', 'colb'], @@ -770,7 +770,7 @@ describe('IndexPattern Data Source suggestions', () => { sourceField: 'bytes', label: 'Avg of bytes', customLabel: true, - operationType: 'avg', + operationType: 'average', }, }, }, @@ -1060,7 +1060,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref: { @@ -1120,7 +1120,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref: { @@ -1468,7 +1468,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1537,7 +1537,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1601,7 +1601,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', - operationType: 'cardinality', + operationType: 'unique_count', }, colb: { label: 'My Op', @@ -1647,7 +1647,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', - operationType: 'cardinality', + operationType: 'unique_count', }, colb: { label: 'My Custom Range', @@ -1723,7 +1723,7 @@ describe('IndexPattern Data Source suggestions', () => { customLabel: true, dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', scale: 'ratio', }, @@ -1843,7 +1843,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field4', }, col5: { @@ -1951,7 +1951,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field1', }, }, @@ -2031,7 +2031,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'field1', }, }, @@ -2091,7 +2091,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 381fa4ca27a49..a7c1074ed4eef 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -165,7 +165,7 @@ const initialState: IndexPatternPrivateState = { label: 'My Op', dataType: 'number', isBucketed: false, - operationType: 'avg', + operationType: 'average', sourceField: 'memory', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index f4fa8bd185b6d..a68f8ae310f3e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -145,7 +145,7 @@ const indexPattern2 = ({ agg: 'histogram', interval: 1000, }, - avg: { + average: { agg: 'avg', }, max: { @@ -569,7 +569,7 @@ describe('loader', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'myfield', }, }, @@ -582,7 +582,7 @@ describe('loader', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'myfield2', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 7a50b1e60d13f..ec7ef6a37a27a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -17,7 +17,7 @@ import { IndexPatternField, IndexPatternLayer, } from './types'; -import { updateLayerIndexPattern } from './operations'; +import { updateLayerIndexPattern, translateToOperationName } from './operations'; import { DateRange, ExistingFields } from '../../common/types'; import { BASE_API_URL } from '../../common'; import { @@ -29,6 +29,7 @@ import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/pub import { documentField } from './document_field'; import { readFromStorage, writeToStorage } from '../settings_storage'; import { getFieldByNameFactory } from './pure_helpers'; +import { memoizedGetAvailableOperationsByMetadata } from './operations'; type SetState = StateSetter; type IndexPatternsService = Pick; @@ -49,6 +50,11 @@ export async function loadIndexPatterns({ return cache; } + if (memoizedGetAvailableOperationsByMetadata.cache.clear) { + // clear operations meta data cache because index pattern reference may change + memoizedGetAvailableOperationsByMetadata.cache.clear(); + } + const allIndexPatterns = await Promise.allSettled( missingIds.map((id) => indexPatternsService.get(id)) ); @@ -103,7 +109,7 @@ export async function loadIndexPatterns({ const restriction = typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]; if (restriction) { - restrictionsObj[agg] = restriction; + restrictionsObj[translateToOperationName(agg)] = restriction; } }); if (Object.keys(restrictionsObj).length) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts index dfb86ec16da30..6ac208913af2e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/__mocks__/index.ts @@ -19,6 +19,7 @@ jest.spyOn(actualHelpers, 'getErrorMessages'); export const { getAvailableOperationsByMetadata, + memoizedGetAvailableOperationsByMetadata, getOperations, getOperationDisplay, getOperationTypesForField, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index b02926fe03fc3..331aa528e6d55 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -82,6 +82,7 @@ export const counterRateOperation: OperationDefinition< scale: 'ratio', references: referenceIds, timeScale, + filter: previousColumn?.filter, params: getFormatFromPreviousColumn(previousColumn), }; }, @@ -106,4 +107,5 @@ export const counterRateOperation: OperationDefinition< )?.join(', '); }, timeScalingMode: 'mandatory', + filterable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 2bf46e3acad1b..1664f3639b598 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -77,6 +77,7 @@ export const cumulativeSumOperation: OperationDefinition< operationType: 'cumulative_sum', isBucketed: false, scale: 'ratio', + filter: previousColumn?.filter, references: referenceIds, params: getFormatFromPreviousColumn(previousColumn), }; @@ -101,4 +102,5 @@ export const cumulativeSumOperation: OperationDefinition< }) )?.join(', '); }, + filterable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx similarity index 94% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 2d4b69a151115..c50e9270eaac1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -19,6 +19,8 @@ import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { OperationDefinition } from '..'; import { getFormatFromPreviousColumn } from '../helpers'; +const OPERATION_NAME = 'differences'; + const ofName = buildLabelFunction((name?: string) => { return i18n.translate('xpack.lens.indexPattern.derivativeOf', { defaultMessage: 'Differences of {name}', @@ -34,14 +36,14 @@ const ofName = buildLabelFunction((name?: string) => { export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn & ReferenceBasedIndexPatternColumn & { - operationType: 'derivative'; + operationType: typeof OPERATION_NAME; }; export const derivativeOperation: OperationDefinition< DerivativeIndexPatternColumn, 'fullReference' > = { - type: 'derivative', + type: OPERATION_NAME, priority: 1, displayName: i18n.translate('xpack.lens.indexPattern.derivative', { defaultMessage: 'Differences', @@ -78,11 +80,12 @@ export const derivativeOperation: OperationDefinition< previousColumn?.timeScale ), dataType: 'number', - operationType: 'derivative', + operationType: OPERATION_NAME, isBucketed: false, scale: 'ratio', references: referenceIds, timeScale: previousColumn?.timeScale, + filter: previousColumn?.filter, params: getFormatFromPreviousColumn(previousColumn), }; }, @@ -108,4 +111,5 @@ export const derivativeOperation: OperationDefinition< )?.join(', '); }, timeScalingMode: 'optional', + filterable: true, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts index f261a0e1e2005..815acb8c4169f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts @@ -7,5 +7,5 @@ export { counterRateOperation, CounterRateIndexPatternColumn } from './counter_rate'; export { cumulativeSumOperation, CumulativeSumIndexPatternColumn } from './cumulative_sum'; -export { derivativeOperation, DerivativeIndexPatternColumn } from './derivative'; +export { derivativeOperation, DerivativeIndexPatternColumn } from './differences'; export { movingAverageOperation, MovingAverageIndexPatternColumn } from './moving_average'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index f9dfc962ce8b7..46cc64c2bc518 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -89,6 +89,7 @@ export const movingAverageOperation: OperationDefinition< scale: 'ratio', references: referenceIds, timeScale: previousColumn?.timeScale, + filter: previousColumn?.filter, params: { window: 5, ...getFormatFromPreviousColumn(previousColumn), @@ -119,6 +120,7 @@ export const movingAverageOperation: OperationDefinition< )?.join(', '); }, timeScalingMode: 'optional', + filterable: true, }; function MovingAverageParamEditor({ @@ -174,7 +176,11 @@ const MovingAveragePopup = () => { setIsPopoverOpen(!isPopoverOpen)}> + { + setIsPopoverOpen(!isPopoverOpen); + }} + > {i18n.translate('xpack.lens.indexPattern.movingAverage.helpText', { defaultMessage: 'How it works', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index 80885a58e17f5..fa1691ba9a78e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -25,7 +25,7 @@ const supportedTypes = new Set([ ]); const SCALE = 'ratio'; -const OPERATION_TYPE = 'cardinality'; +const OPERATION_TYPE = 'unique_count'; const IS_BUCKETED = false; function ofName(name: string) { @@ -40,7 +40,7 @@ function ofName(name: string) { export interface CardinalityIndexPatternColumn extends FormattedIndexPatternColumn, FieldBasedIndexPatternColumn { - operationType: 'cardinality'; + operationType: typeof OPERATION_TYPE; } export const cardinalityOperation: OperationDefinition = { @@ -70,6 +70,7 @@ export const cardinalityOperation: OperationDefinition ofName(getSafeName(column.sourceField, indexPattern)), buildColumn({ field, previousColumn }) { return { @@ -79,6 +80,7 @@ export const cardinalityOperation: OperationDefinition setIsPopoverOpen(!isPopoverOpen)}> + { + setIsPopoverOpen(!isPopoverOpen); + }} + > {i18n.translate('xpack.lens.indexPattern.dateHistogram.autoHelpText', { defaultMessage: 'How it works', })} 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 48c1b8176bea9..6d1cc3254ca7e 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 @@ -10,8 +10,9 @@ import { shallow, mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiPopover, EuiLink } from '@elastic/eui'; import { createMockedIndexPattern } from '../../../mocks'; -import { FilterPopover, QueryInput } from './filter_popover'; +import { FilterPopover } from './filter_popover'; import { LabelInput } from '../shared_components'; +import { QueryInput } from '../../../query_input'; jest.mock('.', () => ({ isQueryValid: () => true, 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 569e394335648..f5428bf24348f 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 @@ -8,13 +8,12 @@ import './filter_popover.scss'; import React, { MouseEventHandler, useEffect, useState } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; import { EuiPopover, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FilterValue, defaultLabel, isQueryValid } from '.'; import { IndexPattern } from '../../../types'; -import { QueryStringInput, Query } from '../../../../../../../../src/plugins/data/public'; +import { Query } from '../../../../../../../../src/plugins/data/public'; import { LabelInput } from '../shared_components'; +import { QueryInput } from '../../../query_input'; export const FilterPopover = ({ filter, @@ -94,54 +93,3 @@ export const FilterPopover = ({ ); }; - -export const QueryInput = ({ - value, - onChange, - indexPattern, - isInvalid, - onSubmit, -}: { - value: Query; - onChange: (input: Query) => void; - indexPattern: IndexPattern; - isInvalid: boolean; - onSubmit: () => void; -}) => { - const [inputValue, setInputValue] = useState(value); - - useDebounce(() => onChange(inputValue), 256, [inputValue]); - - const handleInputChange = (input: Query) => { - setInputValue(input); - }; - - return ( - { - if (inputValue.query) { - onSubmit(); - } - }} - placeholder={ - inputValue.language === 'kuery' - ? i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderKql', { - defaultMessage: '{example}', - values: { example: 'method : "GET" or status : "404"' }, - }) - : i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderLucene', { - defaultMessage: '{example}', - values: { example: 'method:GET OR status:404' }, - }) - } - languageSwitcherPopoverAnchorPosition="rightDown" - /> - ); -}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 03f8375409246..bf563b877ef5e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -26,6 +26,7 @@ import { buildExpressionFunction } from '../../../../../../../../src/plugins/exp import { NewBucketButton, DragDropBuckets, DraggableBucketContainer } from '../shared_components'; const generateId = htmlIdGenerator(); +const OPERATION_NAME = 'filters'; // references types from src/plugins/data/common/search/aggs/buckets/filters.ts export interface Filter { @@ -70,14 +71,14 @@ export const isQueryValid = (input: Query, indexPattern: IndexPattern) => { }; export interface FiltersIndexPatternColumn extends BaseIndexPatternColumn { - operationType: 'filters'; + operationType: typeof OPERATION_NAME; params: { filters: Filter[]; }; } export const filtersOperation: OperationDefinition = { - type: 'filters', + type: OPERATION_NAME, displayName: filtersLabel, priority: 3, // Higher than any metric input: 'none', @@ -86,7 +87,7 @@ export const filtersOperation: OperationDefinition filtersLabel, buildColumn({ previousColumn }) { let params = { filters: [defaultFilter] }; - if (previousColumn?.operationType === 'terms') { + if (previousColumn?.operationType === 'terms' && 'sourceField' in previousColumn) { params = { filters: [ { @@ -103,7 +104,7 @@ export const filtersOperation: OperationDefinition { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', // <= invalid + operationType: 'average', // <= invalid sourceField: 'timestamp', }, createMockedIndexPattern() @@ -46,7 +46,7 @@ describe('helpers', () => { dataType: 'number', isBucketed: false, label: 'Foo', - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, createMockedIndexPattern() diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 433c69466dc00..b3aa93b062eb1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -230,6 +230,7 @@ interface BaseOperationDefinitionProps { * If set to optional, time scaling won't be enabled by default and can be removed. */ timeScalingMode?: TimeScalingMode; + filterable?: boolean; getHelpMessage?: (props: HelpProps) => React.ReactNode; } @@ -436,3 +437,11 @@ export const operationDefinitionMap: Record< (definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }), {} ); + +/** + * Cannot map the prev names, but can guarantee the new names are matching up using the type system + */ +export const renameOperationsMapping: Record = { + avg: 'average', + cardinality: 'unique_count', +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx index 54d884c83020d..4f5c897fb5378 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx @@ -161,12 +161,14 @@ export const lastValueOperation: OperationDefinition { return buildExpressionFunction('aggTopHit', { id: columnId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index b24b08c0be0c3..20580634d12e6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -27,7 +27,7 @@ type MetricColumn = FormattedIndexPatternColumn & const typeToFn: Record = { min: 'aggMin', max: 'aggMax', - avg: 'aggAvg', + average: 'aggAvg', sum: 'aggSum', median: 'aggMedian', }; @@ -76,7 +76,6 @@ function buildMetricOperation>({ }, isTransferable: (column, newIndexPattern) => { const newField = newIndexPattern.getFieldByName(column.sourceField); - return Boolean( newField && supportedTypes.includes(newField.type) && @@ -99,6 +98,7 @@ function buildMetricOperation>({ isBucketed: false, scale: 'ratio', timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined, + filter: previousColumn?.filter, params: getFormatFromPreviousColumn(previousColumn), } as T), onFieldChange: (oldColumn, field) => { @@ -118,11 +118,12 @@ function buildMetricOperation>({ }, getErrorMessage: (layer, columnId, indexPattern) => getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern), + filterable: true, } as OperationDefinition; } export type SumIndexPatternColumn = MetricColumn<'sum'>; -export type AvgIndexPatternColumn = MetricColumn<'avg'>; +export type AvgIndexPatternColumn = MetricColumn<'average'>; export type MinIndexPatternColumn = MetricColumn<'min'>; export type MaxIndexPatternColumn = MetricColumn<'max'>; export type MedianIndexPatternColumn = MetricColumn<'median'>; @@ -152,7 +153,7 @@ export const maxOperation = buildMetricOperation({ }); export const averageOperation = buildMetricOperation({ - type: 'avg', + type: 'average', priority: 2, displayName: i18n.translate('xpack.lens.indexPattern.avg', { defaultMessage: 'Average', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index a31cf9f019480..639b9e3a95c47 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -74,7 +74,10 @@ export const percentileOperation: OperationDefinition { const existingPercentileParam = - previousColumn?.operationType === 'percentile' && previousColumn?.params.percentile; + previousColumn?.operationType === 'percentile' && + previousColumn.params && + 'percentile' in previousColumn.params && + previousColumn.params.percentile; const newPercentileParam = existingPercentileParam || DEFAULT_PERCENTILE_VALUE; return { label: ofName(getSafeName(field.name, indexPattern), newPercentileParam), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index 269c59822fefc..4851b6ff3ec97 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -33,7 +33,11 @@ const GranularityHelpPopover = () => { setIsPopoverOpen(!isPopoverOpen)}> + { + setIsPopoverOpen(!isPopoverOpen); + }} + > {i18n.translate('xpack.lens.indexPattern.ranges.granularityHelpText', { defaultMessage: 'How it works', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 3b0cb67cbce41..a4a061db04797 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -13,9 +13,7 @@ import { EuiSwitch, EuiSwitchEvent, EuiSpacer, - EuiPopover, - EuiButtonEmpty, - EuiText, + EuiAccordion, EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; @@ -24,7 +22,7 @@ import { updateColumnParam, isReferenced } from '../../layer_helpers'; import { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import { getEsAggsSuffix, getInvalidFieldMessage } from '../helpers'; import type { IndexPatternLayer } from '../../../types'; @@ -193,8 +191,6 @@ export const termsOperation: OperationDefinition - { updateLayer( @@ -251,71 +247,6 @@ export const termsOperation: OperationDefinition
- {!hasRestrictions && ( - - { - setPopoverOpen(!popoverOpen); - }} - > - {i18n.translate('xpack.lens.indexPattern.terms.advancedSettings', { - defaultMessage: 'Advanced', - })} - - } - isOpen={popoverOpen} - closePopover={() => { - setPopoverOpen(false); - }} - > - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'otherBucket', - value: e.target.checked, - }) - ) - } - /> - - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'missingBucket', - value: e.target.checked, - }) - ) - } - /> - - - - )} @@ -415,6 +346,57 @@ export const termsOperation: OperationDefinition + {!hasRestrictions && ( + <> + + + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'otherBucket', + value: e.target.checked, + }) + ) + } + /> + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'missingBucket', + value: e.target.checked, + }) + ) + } + /> + + + )} ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 0ed611e9726ef..97b57dee2fde7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; -import { EuiRange, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { EuiFieldNumber, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../../mocks'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; @@ -888,7 +888,7 @@ describe('terms', () => { /> ); - expect(instance.find(EuiRange).prop('value')).toEqual('3'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('3'); }); it('should update state with the size value', () => { @@ -904,7 +904,7 @@ describe('terms', () => { ); act(() => { - instance.find(ValuesRangeInput).prop('onChange')!(7); + instance.find(ValuesInput).prop('onChange')!(7); }); expect(updateLayerSpy).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx similarity index 50% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx index 3603188ba30e5..4303695d6e293 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx @@ -8,52 +8,50 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; -import { EuiRange } from '@elastic/eui'; -import { ValuesRangeInput } from './values_range_input'; +import { EuiFieldNumber } from '@elastic/eui'; +import { ValuesInput } from './values_input'; jest.mock('react-use/lib/useDebounce', () => (fn: () => void) => fn()); -describe('ValuesRangeInput', () => { - it('should render EuiRange correctly', () => { +describe('Values', () => { + it('should render EuiFieldNumber correctly', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); - expect(instance.find(EuiRange).prop('value')).toEqual('5'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('5'); }); it('should not run onChange function on mount', () => { const onChangeSpy = jest.fn(); - shallow(); + shallow(); expect(onChangeSpy.mock.calls.length).toBe(0); }); it('should run onChange function on update', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '7' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '7' }, + } as React.ChangeEvent); }); - expect(instance.find(EuiRange).prop('value')).toEqual('7'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('7'); expect(onChangeSpy.mock.calls.length).toBe(1); expect(onChangeSpy.mock.calls[0][0]).toBe(7); }); it('should not run onChange function on update when value is out of 1-100 range', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '107' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '1007' }, + } as React.ChangeEvent); }); instance.update(); - expect(instance.find(EuiRange).prop('value')).toEqual('107'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('1007'); expect(onChangeSpy.mock.calls.length).toBe(1); - expect(onChangeSpy.mock.calls[0][0]).toBe(100); + expect(onChangeSpy.mock.calls[0][0]).toBe(1000); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx similarity index 88% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 068e13429527f..915e67c4eba0b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -7,10 +7,10 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiRange } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; import { useDebounceWithOptions } from '../helpers'; -export const ValuesRangeInput = ({ +export const ValuesInput = ({ value, onChange, }: { @@ -18,7 +18,7 @@ export const ValuesRangeInput = ({ onChange: (value: number) => void; }) => { const MIN_NUMBER_OF_VALUES = 1; - const MAX_NUMBER_OF_VALUES = 100; + const MAX_NUMBER_OF_VALUES = 1000; const [inputValue, setInputValue] = useState(String(value)); @@ -36,13 +36,11 @@ export const ValuesRangeInput = ({ ); return ( - setInputValue(currentTarget.value)} aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 4f915160a52a8..62cce21ead636 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -120,7 +120,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -147,7 +147,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, }, @@ -365,7 +365,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col2: { @@ -533,7 +533,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col2: { @@ -840,7 +840,7 @@ describe('state_helpers', () => { }, indexPattern, columnId: 'col2', - op: 'avg', + op: 'average', field: indexPattern.fields[2], // bytes field visualizationGroups: [], }); @@ -856,7 +856,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, sourceField: 'bytes', - operationType: 'avg', + operationType: 'average', }), }, incompleteColumns: {}, @@ -967,6 +967,49 @@ describe('state_helpers', () => { ); }); + it('should remove filter from the wrapped column if it gets wrapped (case new1)', () => { + const expectedColumn = { + label: 'Count', + customLabel: true, + dataType: 'number' as const, + isBucketed: false, + sourceField: 'Records', + operationType: 'count' as const, + }; + + const testFilter = { language: 'kuery', query: '' }; + + const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { col1: { ...expectedColumn, filter: testFilter } }, + }; + const result = replaceColumn({ + layer, + indexPattern, + columnId: 'col1', + op: 'testReference' as OperationType, + visualizationGroups: [], + }); + + expect(operationDefinitionMap.testReference.buildColumn).toHaveBeenCalledWith( + expect.objectContaining({ + referenceIds: ['id1'], + previousColumn: expect.objectContaining({ + // filter should be passed to the buildColumn function of the target operation + filter: testFilter, + }), + }) + ); + expect(result.columns).toEqual( + expect.objectContaining({ + // filter should be stripped from the original column + id1: expectedColumn, + col1: expect.any(Object), + }) + ); + }); + it('should create a new no-input operation to use as reference (case new2)', () => { // @ts-expect-error this function is not valid operationDefinitionMap.testReference.requiredReferences = [ @@ -984,7 +1027,7 @@ describe('state_helpers', () => { dataType: 'number' as const, isBucketed: false, sourceField: 'bytes', - operationType: 'avg' as const, + operationType: 'average' as const, }, }, }; @@ -1013,7 +1056,7 @@ describe('state_helpers', () => { { input: ['field'], validateMetadata: () => true, - specificOperations: ['cardinality', 'sum', 'avg'], // this order is ignored + specificOperations: ['unique_count', 'sum', 'average'], // this order is ignored }, ]; const layer: IndexPatternLayer = { @@ -1040,7 +1083,7 @@ describe('state_helpers', () => { expect(result.columnOrder).toEqual(['id1', 'col1']); expect(result.columns).toEqual({ id1: expect.objectContaining({ - operationType: 'avg', + operationType: 'average', }), col1: expect.objectContaining({ operationType: 'testReference', @@ -1054,7 +1097,7 @@ describe('state_helpers', () => { { input: ['field'], validateMetadata: () => true, - specificOperations: ['cardinality'], + specificOperations: ['unique_count'], }, ]; const layer: IndexPatternLayer = { @@ -1079,7 +1122,7 @@ describe('state_helpers', () => { }); expect(result.incompleteColumns).toEqual({ - id1: { operationType: 'cardinality' }, + id1: { operationType: 'unique_count' }, }); expect(result.columns).toEqual({ col1: expect.objectContaining({ @@ -1304,7 +1347,7 @@ describe('state_helpers', () => { columns: { id1: expect.objectContaining({ sourceField: 'timestamp', - operationType: 'cardinality', + operationType: 'unique_count', }), output: expect.objectContaining({ references: ['id1'] }), }, @@ -1420,7 +1463,7 @@ describe('state_helpers', () => { dataType: 'number' as const, isBucketed: false, sourceField: 'bytes', - operationType: 'avg' as const, + operationType: 'average' as const, }; const layer: IndexPatternLayer = { @@ -1432,7 +1475,7 @@ describe('state_helpers', () => { label: 'Reference', dataType: 'number', isBucketed: false, - operationType: 'derivative', + operationType: 'differences', references: ['metric'], }, }, @@ -1786,7 +1829,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }; @@ -1872,7 +1915,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, col3: { @@ -1920,7 +1963,7 @@ describe('state_helpers', () => { isBucketed: false, // Private - operationType: 'avg', + operationType: 'average', sourceField: 'bytes', }, ref2: { @@ -1963,7 +2006,7 @@ describe('state_helpers', () => { searchable: true, type: 'number', aggregationRestrictions: { - avg: { + average: { agg: 'avg', }, }, @@ -2033,7 +2076,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'xxx', }, }, @@ -2064,7 +2107,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'fieldB', }, }, @@ -2127,7 +2170,7 @@ describe('state_helpers', () => { dataType: 'number', isBucketed: false, label: '', - operationType: 'avg', + operationType: 'average', sourceField: 'fieldD', }, }, @@ -2177,14 +2220,14 @@ describe('state_helpers', () => { describe('getErrorMessages', () => { it('should collect errors from metric-type operation definitions', () => { const mock = jest.fn().mockReturnValue(['error 1']); - operationDefinitionMap.avg.getErrorMessage = mock; + operationDefinitionMap.average.getErrorMessage = mock; const errors = getErrorMessages( { indexPatternId: '1', columnOrder: [], columns: { // @ts-expect-error invalid column - col1: { operationType: 'avg' }, + col1: { operationType: 'average' }, }, }, indexPattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 3a67e8e464323..7853b7da7956e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -509,7 +509,18 @@ function applyReferenceTransition({ if (!hasExactMatch && isColumnValidAsReference({ validation, column: previousColumn })) { hasExactMatch = true; - const newLayer = { ...layer, columns: { ...layer.columns, [newId]: { ...previousColumn } } }; + const newLayer = { + ...layer, + columns: { + ...layer.columns, + [newId]: { + ...previousColumn, + // drop the filter for the referenced column because the wrapping operation + // is filterable as well and will handle it one level higher. + filter: operationDefinition.filterable ? undefined : previousColumn.filter, + }, + }, + }; layer = { ...layer, columnOrder: getColumnOrder(newLayer), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts index d5ea34f003561..429d881341e79 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/mocks.ts @@ -32,6 +32,7 @@ export const createMockedReferenceOperation = () => { references: args.referenceIds, }; }), + filterable: true, isTransferable: jest.fn(), toExpression: jest.fn().mockReturnValue([]), getPossibleOperation: jest.fn().mockReturnValue({ dataType: 'number', isBucketed: false }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 8c5dee8bbb28f..4c54b777b66f3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -56,7 +56,7 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(['terms', 'cardinality', 'last_value']); + ).toEqual(['terms', 'unique_count', 'last_value']); }); it('should return only bucketed operations on strings when passed proper filterOperations function', () => { @@ -87,11 +87,11 @@ describe('getOperationTypesForField', () => { 'range', 'terms', 'median', - 'avg', + 'average', 'sum', 'min', 'max', - 'cardinality', + 'unique_count', 'percentile', 'last_value', ]); @@ -109,7 +109,16 @@ describe('getOperationTypesForField', () => { }, (op) => !op.isBucketed ) - ).toEqual(['median', 'avg', 'sum', 'min', 'max', 'cardinality', 'percentile', 'last_value']); + ).toEqual([ + 'median', + 'average', + 'sum', + 'min', + 'max', + 'unique_count', + 'percentile', + 'last_value', + ]); }); it('should return operations on dates', () => { @@ -286,7 +295,7 @@ describe('getOperationTypesForField', () => { }, Object { "field": "bytes", - "operationType": "avg", + "operationType": "average", "type": "field", }, Object { @@ -303,7 +312,7 @@ describe('getOperationTypesForField', () => { "type": "fullReference", }, Object { - "operationType": "derivative", + "operationType": "differences", "type": "fullReference", }, Object { @@ -322,17 +331,17 @@ describe('getOperationTypesForField', () => { }, Object { "field": "timestamp", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { "field": "bytes", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { "field": "source", - "operationType": "cardinality", + "operationType": "unique_count", "type": "field", }, Object { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index 63671fe35e99e..a45650f9323f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -5,19 +5,26 @@ * 2.0. */ -import _ from 'lodash'; +import { memoize } from 'lodash'; import { OperationMetadata } from '../../types'; import { operationDefinitionMap, operationDefinitions, GenericOperationDefinition, OperationType, + renameOperationsMapping, } from './definitions'; import { IndexPattern, IndexPatternField } from '../types'; import { documentField } from '../document_field'; export { operationDefinitionMap } from './definitions'; - +/** + * Map aggregation names from Elasticsearch to Lens names. + * Used when loading indexpatterns to map metadata (i.e. restrictions) + */ +export function translateToOperationName(agg: string): OperationType { + return agg in renameOperationsMapping ? renameOperationsMapping[agg] : (agg as OperationType); +} /** * Returns all available operation types as a list at runtime. * This will be an array of each member of the union type `OperationType` @@ -187,3 +194,5 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { return Object.values(operationByMetadata); } + +export const memoizedGetAvailableOperationsByMetadata = memoize(getAvailableOperationsByMetadata); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx new file mode 100644 index 0000000000000..50941148342c3 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; +import { IndexPattern } from './types'; +import { QueryStringInput, Query } from '../../../../../src/plugins/data/public'; + +export const QueryInput = ({ + value, + onChange, + indexPattern, + isInvalid, + onSubmit, + disableAutoFocus, +}: { + value: Query; + onChange: (input: Query) => void; + indexPattern: IndexPattern; + isInvalid: boolean; + onSubmit: () => void; + disableAutoFocus?: boolean; +}) => { + const [inputValue, setInputValue] = useState(value); + + useDebounce(() => onChange(inputValue), 256, [inputValue]); + + const handleInputChange = (input: Query) => { + setInputValue(input); + }; + + return ( + { + if (inputValue.query) { + onSubmit(); + } + }} + placeholder={ + inputValue.language === 'kuery' + ? i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderKql', { + defaultMessage: '{example}', + values: { example: 'method : "GET" or status : "404"' }, + }) + : i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderLucene', { + defaultMessage: '{example}', + values: { example: 'method:GET OR status:404' }, + }) + } + languageSwitcherPopoverAnchorPosition="rightDown" + /> + ); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 6a0fbb3006847..d786d781199b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -7,6 +7,7 @@ import type { IUiSettingsClient } from 'kibana/public'; import { + AggFunctionsMapping, EsaggsExpressionFunctionDefinition, IndexPatternLoadExpressionFunctionDefinition, } from '../../../../../src/plugins/data/public'; @@ -29,11 +30,32 @@ function getExpressionForLayer( indexPattern: IndexPattern, uiSettings: IUiSettingsClient ): ExpressionAstExpression | null { - const { columns, columnOrder } = layer; + const { columnOrder } = layer; if (columnOrder.length === 0 || !indexPattern) { return null; } + const columns = { ...layer.columns }; + Object.keys(columns).forEach((columnId) => { + const column = columns[columnId]; + const rootDef = operationDefinitionMap[column.operationType]; + if ( + 'references' in column && + rootDef.filterable && + rootDef.input === 'fullReference' && + column.filter + ) { + // inherit filter to all referenced operations + column.references.forEach((referenceColumnId) => { + const referencedColumn = columns[referenceColumnId]; + const referenceDef = operationDefinitionMap[column.operationType]; + if (referenceDef.filterable) { + columns[referenceColumnId] = { ...referencedColumn, filter: column.filter }; + } + }); + } + }); + const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); if (columnEntries.length) { @@ -44,10 +66,37 @@ function getExpressionForLayer( if (def.input === 'fullReference') { expressions.push(...def.toExpression(layer, colId, indexPattern)); } else { + const wrapInFilter = Boolean(def.filterable && col.filter); + let aggAst = def.toEsAggsFn( + col, + wrapInFilter ? `${colId}-metric` : colId, + indexPattern, + layer, + uiSettings + ); + if (wrapInFilter) { + aggAst = buildExpressionFunction( + 'aggFilteredMetric', + { + id: colId, + enabled: true, + schema: 'metric', + customBucket: buildExpression([ + buildExpressionFunction('aggFilter', { + id: `${colId}-filter`, + enabled: true, + schema: 'bucket', + filter: JSON.stringify(col.filter), + }), + ]), + customMetric: buildExpression({ type: 'expression', chain: [aggAst] }), + } + ).toAst(); + } aggs.push( buildExpression({ type: 'expression', - chain: [def.toEsAggsFn(col, colId, indexPattern, layer, uiSettings)], + chain: [aggAst], }) ); } diff --git a/x-pack/plugins/lens/public/mocks.ts b/x-pack/plugins/lens/public/mocks.ts index 10d3be1d1b57d..fd1e38db242a8 100644 --- a/x-pack/plugins/lens/public/mocks.ts +++ b/x-pack/plugins/lens/public/mocks.ts @@ -14,6 +14,7 @@ const createStartContract = (): Start => { EmbeddableComponent: jest.fn(() => null), canUseEditor: jest.fn(() => true), navigateToPrefilledEditor: jest.fn(), + getXyVisTypes: jest.fn(), }; return startContract; }; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index fc7e4464624f4..aed4db2e88e21 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -42,7 +42,7 @@ import { VISUALIZE_FIELD_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; -import { EditorFrameStart } from './types'; +import type { EditorFrameStart, VisualizationType } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; import { getSearchProvider } from './search_provider'; @@ -101,6 +101,11 @@ export interface LensPublicStart { * Method which returns true if the user has permission to use Lens as defined by application capabilities. */ canUseEditor: () => boolean; + + /** + * Method which returns xy VisualizationTypes array keeping this async as to not impact page load bundle + */ + getXyVisTypes: () => Promise; } export class LensPlugin { @@ -257,6 +262,10 @@ export class LensPlugin { canUseEditor: () => { return Boolean(core.application.capabilities.visualize?.show); }, + getXyVisTypes: async () => { + const { visualizationTypes } = await import('./xy_visualization/types'); + return visualizationTypes; + }, }; } diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index c0788e6f67dfe..18c73a01cf784 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -15,6 +15,7 @@ const typeToIconMap: { [type: string]: string | IconType } = { labels: 'visText', values: 'number', list: 'list', + visualOptions: 'brush', }; export interface ToolbarPopoverProps { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 982f513ae1019..1130bd7a95d88 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -27,6 +27,9 @@ Object { "type": "expression", }, ], + "curveType": Array [ + "LINEAR", + ], "description": Array [ "", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 0bf5c139e2403..5615a9ac34898 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -24,6 +24,7 @@ import { HorizontalAlignment, ElementClickListener, BrushEndListener, + CurveType, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -179,6 +180,13 @@ export const xyChart: ExpressionFunctionDefinition< help: 'Layers of visual series', multi: true, }, + curveType: { + types: ['string'], + options: ['LINEAR', 'CURVE_MONOTONE_X'], + help: i18n.translate('xpack.lens.xyChart.curveType.help', { + defaultMessage: 'Define how curve type is rendered for a line chart', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -773,10 +781,17 @@ export function XYChart({ const index = `${layerIndex}-${accessorIndex}`; + const curveType = args.curveType ? CurveType[args.curveType] : undefined; + switch (seriesType) { case 'line': return ( - + ); case 'bar': case 'bar_stacked': @@ -804,11 +819,17 @@ export function XYChart({ key={index} {...seriesProps} fit={isPercentage ? 'zero' : getFitOptions(fittingFunction)} + curve={curveType} /> ); case 'area': return ( - + ); default: return assertNever(seriesType); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 331e27a8efdb0..6a1882edde949 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -148,6 +148,7 @@ export const buildExpression = ( }, ], fittingFunction: [state.fittingFunction || 'None'], + curveType: [state.curveType || 'LINEAR'], axisTitlesVisibilitySettings: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 126be41e7b129..6f1a01acd6e76 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -413,8 +413,11 @@ export interface XYArgs { }; tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; + curveType?: XYCurveType; } +export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; + // Persisted parts of the state export interface XYState { preferredSeriesType: SeriesType; @@ -428,6 +431,7 @@ export interface XYState { axisTitlesVisibilitySettings?: AxesSettingsConfig; tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; + curveType?: XYCurveType; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx new file mode 100644 index 0000000000000..c37a36a42fa47 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; +import { EuiSwitch } from '@elastic/eui'; +import { LineCurveOption } from './line_curve_option'; + +describe('Line curve option', () => { + it('should show currently selected line curve option', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(true); + }); + + it('should show currently curving disabled', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(false); + }); + + it('should show curving option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(true); + }); + + it('should hide curve option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx new file mode 100644 index 0000000000000..ea0a1553ba5e5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { XYCurveType } from '../types'; + +export interface LineCurveOptionProps { + /** + * Currently selected value + */ + value?: XYCurveType; + /** + * Callback on display option change + */ + onChange: (id: XYCurveType) => void; + isCurveTypeEnabled?: boolean; +} + +export const LineCurveOption: React.FC = ({ + onChange, + value, + isCurveTypeEnabled = true, +}) => { + return isCurveTypeEnabled ? ( + <> + + { + if (e.target.checked) { + onChange('CURVE_MONOTONE_X'); + } else { + onChange('LINEAR'); + } + }} + data-test-subj="lnsCurveStyleToggle" + /> + + + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx new file mode 100644 index 0000000000000..851b14839d7f7 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallowWithIntl as shallow, mountWithIntl as mount } from '@kbn/test/jest'; +import { EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Missing values option', () => { + it('should show currently selected fitting function', () => { + const component = shallow( + + ); + + expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); + }); + + it('should show currently selected value labels display setting', () => { + const component = mount( + + ); + + expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); + }); + + it('should show display field when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); + }); + + it('should hide in display value label option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); + }); + + it('should show the fitting option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(true); + }); + + it('should hide the fitting option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx new file mode 100644 index 0000000000000..fb6ecec4d2801 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; 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 { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { FittingFunction, fittingFunctionDefinitions } from '../fitting_functions'; +import { ValueLabelConfig } from '../types'; + +export interface MissingValuesOptionProps { + valueLabels?: ValueLabelConfig; + fittingFunction?: FittingFunction; + onValueLabelChange: (newMode: ValueLabelConfig) => void; + onFittingFnChange: (newMode: FittingFunction) => void; + isValueLabelsEnabled?: boolean; + isFittingEnabled?: boolean; +} + +const valueLabelsOptions: Array<{ + id: string; + value: 'hide' | 'inside' | 'outside'; + label: string; + 'data-test-subj': string; +}> = [ + { + id: `value_labels_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lnsXY_valueLabels_hide', + }, + { + id: `value_labels_inside`, + value: 'inside', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { + defaultMessage: 'Show', + }), + 'data-test-subj': 'lnsXY_valueLabels_inside', + }, +]; + +export const MissingValuesOptions: React.FC = ({ + onValueLabelChange, + onFittingFnChange, + valueLabels, + fittingFunction, + isValueLabelsEnabled = true, + isFittingEnabled = true, +}) => { + const valueLabelsVisibilityMode = valueLabels || 'hide'; + + return ( + <> + {isValueLabelsEnabled && ( + + {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { + defaultMessage: 'Labels', + })} + + } + > + value === valueLabelsVisibilityMode)!.id + } + onChange={(modeId) => { + const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; + onValueLabelChange(newMode); + }} + /> + + )} + {isFittingEnabled && ( + + {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { + defaultMessage: 'Missing values', + })}{' '} + + + } + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={fittingFunction || 'None'} + onChange={(value) => onFittingFnChange(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ )} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx new file mode 100644 index 0000000000000..e7ec395312bff --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -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 React from 'react'; +import { shallowWithIntl as shallow } from '@kbn/test/jest'; +import { Position } from '@elastic/charts'; +import { FramePublicAPI } from '../../types'; +import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { State } from '../types'; +import { VisualOptionsPopover } from './visual_options_popover'; +import { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Visual options popover', () => { + let frame: FramePublicAPI; + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + layers: [ + { + seriesType: 'bar', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + } + + beforeEach(() => { + frame = createMockFramePublicAPI(); + frame.datasourceLayers = { + first: createMockDatasource('test').publicAPIMock, + }; + }); + it('should disable the visual options for stacked bar charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should disable the values and fitting for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should not disable the visual options for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(false); + }); + + it('should disabled the popover if there is histogram series', () => { + // make it detect an histogram series + frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ + isBucketed: true, + scale: 'interval', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should hide the fitting option for bar series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should show the popover and display field enabled for bar and horizontal_bar series', () => { + const state = testState(); + + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); + + it('should hide in the popover the display option for area and line series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + }); + + it('should keep the display option for bar series with multiple layers', () => { + frame.datasourceLayers = { + ...frame.datasourceLayers, + second: createMockDatasource('test').publicAPIMock, + }; + + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx new file mode 100644 index 0000000000000..fcdef86cc5d0e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; +import { LineCurveOption } from './line_curve_option'; +import { XYState } from '../types'; +import { hasHistogramSeries } from '../state_helpers'; +import { ValidLayer } from '../types'; +import { TooltipWrapper } from '../tooltip_wrapper'; +import { FramePublicAPI } from '../../types'; + +function getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, +}: { + isAreaPercentage: boolean; + isHistogramSeries: boolean; +}): string { + if (isHistogramSeries) { + return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on histograms.', + }); + } + if (isAreaPercentage) { + return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on percentage area charts.', + }); + } + return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', + }); +} + +export interface VisualOptionsPopoverProps { + state: XYState; + setState: (newState: XYState) => void; + datasourceLayers: FramePublicAPI['datasourceLayers']; +} + +export const VisualOptionsPopover: React.FC = ({ + state, + setState, + datasourceLayers, +}) => { + const isAreaPercentage = state?.layers.some( + ({ seriesType }) => seriesType === 'area_percentage_stacked' + ); + + const hasNonBarSeries = state?.layers.some(({ seriesType }) => + ['area_stacked', 'area', 'line'].includes(seriesType) + ); + + const hasBarNotStacked = state?.layers.some(({ seriesType }) => + ['bar', 'bar_horizontal'].includes(seriesType) + ); + + const isHistogramSeries = Boolean( + hasHistogramSeries(state?.layers as ValidLayer[], datasourceLayers) + ); + + const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; + const isFittingEnabled = hasNonBarSeries; + const isCurveTypeEnabled = hasNonBarSeries || isAreaPercentage; + + const valueLabelsDisabledReason = getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, + }); + + const isDisabled = !isValueLabelsEnabled && !isFittingEnabled && !isCurveTypeEnabled; + + return ( + + + { + setState({ + ...state, + curveType: id, + }); + }} + /> + + { + setState({ ...state, valueLabels: newMode }); + }} + onFittingFnChange={(newVal) => { + setState({ ...state, fittingFunction: newVal }); + }} + /> + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 40ac4958aefb9..f965140a48ca0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; -import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; import { State } from './types'; @@ -101,179 +100,6 @@ describe('XY Config panels', () => { }); describe('XyToolbar', () => { - it('should show currently selected fitting function', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); - }); - - it('should show currently selected value labels display setting', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); - }); - - it('should disable the popover for stacked bar charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disable the popover for percentage area charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disabled the popover if there is histogram series', () => { - // make it detect an histogram series - frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ - isBucketed: true, - scale: 'interval', - }); - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should show the popover and display field enabled for bar and horizontal_bar series', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - - it('should hide the fitting option for bar series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); - }); - - it('should hide in the popover the display option for area and line series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); - }); - - it('should keep the display option for bar series with multiple layers', () => { - frame.datasourceLayers = { - ...frame.datasourceLayers, - second: createMockDatasource('test').publicAPIMock, - }; - - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - it('should disable the popover if there is no right axis', () => { const state = testState(); const component = shallow(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index ac08c55eeadbf..d7868a17bf9db 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -14,15 +14,12 @@ import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, - EuiSuperSelect, EuiFormRow, - EuiText, htmlIdGenerator, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon, - EuiIconTip, } from '@elastic/eui'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { @@ -31,29 +28,17 @@ import { VisualizationDimensionEditorProps, FormatFactory, } from '../types'; -import { - State, - SeriesType, - visualizationTypes, - YAxisMode, - AxesSettingsConfig, - ValidLayer, -} from './types'; -import { - isHorizontalChart, - isHorizontalSeries, - getSeriesColor, - hasHistogramSeries, -} from './state_helpers'; +import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types'; +import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { fittingFunctionDefinitions } from './fitting_functions'; -import { ToolbarPopover, LegendSettingsPopover } from '../shared_components'; +import { LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getSortedAccessors } from './to_expression'; +import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -92,30 +77,6 @@ const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: }, ]; -const valueLabelsOptions: Array<{ - id: string; - value: 'hide' | 'inside' | 'outside'; - label: string; - 'data-test-subj': string; -}> = [ - { - id: `value_labels_hide`, - value: 'hide', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { - defaultMessage: 'Hide', - }), - 'data-test-subj': 'lnsXY_valueLabels_hide', - }, - { - id: `value_labels_inside`, - value: 'inside', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { - defaultMessage: 'Show', - }), - 'data-test-subj': 'lnsXY_valueLabels_inside', - }, -]; - export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -159,46 +120,9 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } -function getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, -}: { - isAreaPercentage: boolean; - isHistogramSeries: boolean; -}): string { - if (isHistogramSeries) { - return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on histograms.', - }); - } - if (isAreaPercentage) { - return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on percentage area charts.', - }); - } - return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', - }); -} export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; - const hasNonBarSeries = state?.layers.some(({ seriesType }) => - ['area_stacked', 'area', 'line'].includes(seriesType) - ); - - const hasBarNotStacked = state?.layers.some(({ seriesType }) => - ['bar', 'bar_horizontal'].includes(seriesType) - ); - - const isAreaPercentage = state?.layers.some( - ({ seriesType }) => seriesType === 'area_percentage_stacked' - ); - - const isHistogramSeries = Boolean( - hasHistogramSeries(state?.layers as ValidLayer[], frame.datasourceLayers) - ); - const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false; const axisGroups = getAxesConfiguration(state?.layers, shouldRotate); @@ -267,113 +191,15 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp ? 'hide' : 'show'; - const valueLabelsVisibilityMode = state?.valueLabels || 'hide'; - - const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; - const isFittingEnabled = hasNonBarSeries; - - const valueLabelsDisabledReason = getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, - }); - return ( - - - {isValueLabelsEnabled ? ( - - {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { - defaultMessage: 'Labels', - })} - - } - > - value === valueLabelsVisibilityMode)! - .id - } - onChange={(modeId) => { - const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; - setState({ ...state, valueLabels: newMode }); - }} - /> - - ) : null} - {isFittingEnabled ? ( - - {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { - defaultMessage: 'Missing values', - })}{' '} - - - } - > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; - })} - valueOfSelected={state?.fittingFunction || 'None'} - onChange={(value) => setState({ ...state, fittingFunction: value })} - itemLayoutAlign="top" - hasDividers - /> -
- ) : null} -
-
+ { expect(result.attributes.title).toEqual(example.attributes.title); }); }); + + describe('7.13.0 rename operations for Formula', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '21c145c0-8667-11eb-b6a9-a5bf52bdf519', + title: 'MyRenamedOps', + description: '', + visualizationType: 'lnsXY', + state: { + datasourceStates: { + indexpattern: { + layers: { + '5ab74ddc-93ca-44e2-9857-ecf85c86b53e': { + columns: { + '2e57a41e-5a52-42d3-877f-bd211d903ef8': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0': { + label: 'Unique count of agent.keyword', + dataType: 'number', + operationType: 'cardinality', + scale: 'ratio', + sourceField: 'agent.keyword', + isBucketed: false, + }, + 'e5efca70-edb5-4d6d-a30a-79384066987e': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'avg', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f': { + label: 'Differences of bytes', + dataType: 'number', + operationType: 'derivative', + isBucketed: false, + scale: 'ratio', + references: ['9ca33a9b-f2e6-46ef-a5e1-14bfbe262605'], + }, + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'avg', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '2e57a41e-5a52-42d3-877f-bd211d903ef8', + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + incompleteColumns: {}, + }, + }, + }, + }, + visualization: { + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '5ab74ddc-93ca-44e2-9857-ecf85c86b53e', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + }; + + it('should rename only specific operation types', () => { + const result = migrations['7.13.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + const layers = result.attributes.state.datasourceStates.indexpattern.layers; + expect(layers).toEqual({ + '5ab74ddc-93ca-44e2-9857-ecf85c86b53e': { + columns: { + '2e57a41e-5a52-42d3-877f-bd211d903ef8': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0': { + label: 'Unique count of agent.keyword', + dataType: 'number', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'agent.keyword', + isBucketed: false, + }, + 'e5efca70-edb5-4d6d-a30a-79384066987e': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'average', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f': { + label: 'Differences of bytes', + dataType: 'number', + operationType: 'differences', + isBucketed: false, + scale: 'ratio', + references: ['9ca33a9b-f2e6-46ef-a5e1-14bfbe262605'], + }, + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'average', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '2e57a41e-5a52-42d3-877f-bd211d903ef8', + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '9ca33a9b-f2e6-46ef-a5e1-14bfbe262605', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + incompleteColumns: {}, + }, + }); + // should leave other parts alone + expect(result.attributes.state.visualization).toEqual(example.attributes.state.visualization); + expect(result.attributes.state.query).toEqual(example.attributes.state.query); + expect(result.attributes.state.filters).toEqual(example.attributes.state.filters); + expect(result.attributes.title).toEqual(example.attributes.title); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index 4c6dfcd7949be..430c1a6caa667 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -106,6 +106,86 @@ interface DatatableStatePost711 { }; } +type OperationTypePre712 = + | 'avg' + | 'cardinality' + | 'derivative' + | 'filters' + | 'terms' + | 'date_histogram' + | 'min' + | 'max' + | 'sum' + | 'median' + | 'percentile' + | 'last_value' + | 'count' + | 'range' + | 'cumulative_sum' + | 'counter_rate' + | 'moving_average'; +type OperationTypePost712 = Exclude< + OperationTypePre712 | 'average' | 'unique_count' | 'differences', + 'avg' | 'cardinality' | 'derivative' +>; +interface LensDocShapePre712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePre712; + } + >; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} + +interface LensDocShapePost712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + currentIndexPatternId: string; + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePost712; + } + >; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} + /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} @@ -387,6 +467,44 @@ const transformTableState: SavedObjectMigrationFn< return newDoc; }; +const renameOperationsForFormula: SavedObjectMigrationFn< + LensDocShapePre712, + LensDocShapePost712 +> = (doc) => { + const renameMapping = { + avg: 'average', + cardinality: 'unique_count', + derivative: 'differences', + } as const; + function shouldBeRenamed(op: OperationTypePre712): op is keyof typeof renameMapping { + return op in renameMapping; + } + const newDoc = cloneDeep(doc); + const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {}; + (newDoc.attributes as LensDocShapePost712).state.datasourceStates.indexpattern.layers = Object.fromEntries( + Object.entries(datasourceLayers).map(([layerId, layer]) => { + return [ + layerId, + { + ...layer, + columns: Object.fromEntries( + Object.entries(layer.columns).map(([columnId, column]) => { + const copy = { + ...column, + operationType: shouldBeRenamed(column.operationType) + ? renameMapping[column.operationType] + : column.operationType, + }; + return [columnId, copy]; + }) + ), + }, + ]; + }) + ); + return newDoc as SavedObjectUnsanitizedDoc; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -395,4 +513,5 @@ export const migrations: SavedObjectMigrationMap = { '7.10.0': extractReferences, '7.11.0': removeSuggestedPriority, '7.12.0': transformTableState, + '7.13.0': renameOperationsForFormula, }; diff --git a/x-pack/plugins/lens/server/routes/existing_fields.test.ts b/x-pack/plugins/lens/server/routes/existing_fields.test.ts index 3f3e94099f666..57d8ebf678d61 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.test.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.test.ts @@ -22,7 +22,7 @@ describe('existingFields', () => { } function searchResults(fields: Record = {}) { - return { fields }; + return { fields, _index: '_index', _id: '_id' }; } it('should handle root level fields', () => { @@ -77,7 +77,13 @@ describe('existingFields', () => { it('supports meta fields', () => { const result = existingFields( - [{ _mymeta: 'abc', ...searchResults({ bar: ['scriptvalue'] }) }], + [ + { + // @ts-expect-error _mymeta is not defined on estypes.Hit + _mymeta: 'abc', + ...searchResults({ bar: ['scriptvalue'] }), + }, + ], [field({ name: '_mymeta', isMeta: true })] ); diff --git a/x-pack/plugins/lens/server/routes/existing_fields.ts b/x-pack/plugins/lens/server/routes/existing_fields.ts index 8a2db992a839d..2e6d612835231 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { errors } from '@elastic/elasticsearch'; +import { errors, estypes } from '@elastic/elasticsearch'; import { schema } from '@kbn/config-schema'; import { RequestHandlerContext, ElasticsearchClient } from 'src/core/server'; import { CoreSetup, Logger } from 'src/core/server'; @@ -192,18 +192,19 @@ async function fetchIndexPatternStats({ _source: false, runtime_mappings: runtimeFields.reduce((acc, field) => { if (!field.runtimeField) return acc; + // @ts-expect-error @elastic/elasticsearch StoredScript.language is required acc[field.name] = field.runtimeField; return acc; - }, {} as Record), + }, {} as Record), script_fields: scriptedFields.reduce((acc, field) => { acc[field.name] = { script: { - lang: field.lang, - source: field.script, + lang: field.lang!, + source: field.script!, }, }; return acc; - }, {} as Record), + }, {} as Record), }, }); return result.hits.hits; @@ -212,10 +213,7 @@ async function fetchIndexPatternStats({ /** * Exported only for unit tests. */ -export function existingFields( - docs: Array<{ fields: Record; [key: string]: unknown }>, - fields: Field[] -): string[] { +export function existingFields(docs: estypes.Hit[], fields: Field[]): string[] { const missingFields = new Set(fields); for (const doc of docs) { @@ -224,7 +222,7 @@ export function existingFields( } missingFields.forEach((field) => { - let fieldStore: Record = doc.fields; + let fieldStore = doc.fields!; if (field.isMeta) { fieldStore = doc; } diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts index 49ea8c2076f7a..6cddd2c60f416 100644 --- a/x-pack/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/plugins/lens/server/routes/field_stats.ts @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { errors } from '@elastic/elasticsearch'; +import { errors, estypes } from '@elastic/elasticsearch'; import DateMath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; import { CoreSetup } from 'src/core/server'; @@ -79,13 +78,14 @@ export async function initFieldsRoute(setup: CoreSetup) { }, }; - const search = async (aggs: unknown) => { + const search = async (aggs: Record) => { const { body: result } = await requestClient.search({ index: indexPattern.title, track_total_hits: true, body: { query, aggs, + // @ts-expect-error @elastic/elasticsearch StoredScript.language is required runtime_mappings: field.runtimeField ? { [fieldName]: field.runtimeField } : {}, }, size: 0, @@ -135,7 +135,7 @@ export async function initFieldsRoute(setup: CoreSetup) { } export async function getNumberHistogram( - aggSearchWithBody: (body: unknown) => Promise, + aggSearchWithBody: (aggs: Record) => Promise, field: IFieldType, useTopHits = true ): Promise { @@ -179,7 +179,10 @@ export async function getNumberHistogram( const terms = 'top_values' in minMaxResult.aggregations!.sample ? minMaxResult.aggregations!.sample.top_values - : { buckets: [] }; + : { + buckets: [] as Array<{ doc_count: number; key: string | number }>, + }; + const topValuesBuckets = { buckets: terms.buckets.map((bucket) => ({ count: bucket.doc_count, @@ -241,7 +244,7 @@ export async function getNumberHistogram( } export async function getStringSamples( - aggSearchWithBody: (body: unknown) => unknown, + aggSearchWithBody: (aggs: Record) => unknown, field: IFieldType ): Promise { const fieldRef = getFieldRef(field); @@ -280,7 +283,7 @@ export async function getStringSamples( // This one is not sampled so that it returns the full date range export async function getDateHistogram( - aggSearchWithBody: (body: unknown) => unknown, + aggSearchWithBody: (aggs: Record) => unknown, field: IFieldType, range: { fromDate: string; toDate: string } ): Promise { diff --git a/x-pack/plugins/lens/server/usage/schema.ts b/x-pack/plugins/lens/server/usage/schema.ts index 158e62ee8cfd8..ab3945a0162a6 100644 --- a/x-pack/plugins/lens/server/usage/schema.ts +++ b/x-pack/plugins/lens/server/usage/schema.ts @@ -10,6 +10,10 @@ import { LensUsage } from './types'; const eventsSchema: MakeSchemaFrom = { app_query_change: { type: 'long' }, + open_help_popover: { + type: 'long', + _meta: { description: 'Number of times the user opened one of the in-product help popovers.' }, + }, indexpattern_field_info_click: { type: 'long' }, loaded: { type: 'long' }, app_filters_updated: { type: 'long' }, @@ -34,6 +38,44 @@ const eventsSchema: MakeSchemaFrom = { xy_change_layer_display: { type: 'long' }, xy_layer_removed: { type: 'long' }, xy_layer_added: { type: 'long' }, + open_field_editor_edit: { + type: 'long', + _meta: { + description: + 'Number of times the user opened the editor flyout to edit a field from within Lens.', + }, + }, + open_field_editor_add: { + type: 'long', + _meta: { + description: + 'Number of times the user opened the editor flyout to add a field from within Lens.', + }, + }, + save_field_edit: { + type: 'long', + _meta: { + description: 'Number of times the user edited a field from within Lens.', + }, + }, + save_field_add: { + type: 'long', + _meta: { + description: 'Number of times the user added a field from within Lens.', + }, + }, + open_field_delete_modal: { + type: 'long', + _meta: { + description: 'Number of times the user opened the field delete modal from within Lens.', + }, + }, + delete_field: { + type: 'long', + _meta: { + description: 'Number of times the user deleted a field from within Lens.', + }, + }, indexpattern_dimension_operation_terms: { type: 'long', _meta: { diff --git a/x-pack/plugins/lens/server/usage/task.ts b/x-pack/plugins/lens/server/usage/task.ts index d583e1628cbe8..9c9ab7fd0b350 100644 --- a/x-pack/plugins/lens/server/usage/task.ts +++ b/x-pack/plugins/lens/server/usage/task.ts @@ -137,14 +137,17 @@ export async function getDailyEvents( const byDateByType: Record> = {}; const suggestionsByDate: Record> = {}; + // @ts-expect-error no way to declare aggregations for search response metrics.aggregations!.daily.buckets.forEach((daily) => { const byType: Record = byDateByType[daily.key] || {}; + // @ts-expect-error no way to declare aggregations for search response daily.groups.buckets.regularEvents.names.buckets.forEach((bucket) => { byType[bucket.key] = (bucket.sums.value || 0) + (byType[daily.key] || 0); }); byDateByType[daily.key] = byType; const suggestionsByType: Record = suggestionsByDate[daily.key] || {}; + // @ts-expect-error no way to declare aggregations for search response daily.groups.buckets.suggestionEvents.names.buckets.forEach((bucket) => { suggestionsByType[bucket.key] = (bucket.sums.value || 0) + (suggestionsByType[daily.key] || 0); diff --git a/x-pack/plugins/lens/server/usage/visualization_counts.ts b/x-pack/plugins/lens/server/usage/visualization_counts.ts index 5b084ecfef5e4..3b9bb99caf5b8 100644 --- a/x-pack/plugins/lens/server/usage/visualization_counts.ts +++ b/x-pack/plugins/lens/server/usage/visualization_counts.ts @@ -50,6 +50,7 @@ export async function getVisualizationCounts( }, }); + // @ts-expect-error @elastic/elasticsearch no way to declare aggregations for search response const buckets = results.aggregations.groups.buckets; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts b/x-pack/plugins/lists/public/common/empty_value.ts similarity index 70% rename from x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts rename to x-pack/plugins/lists/public/common/empty_value.ts index 0e57470d3de05..05a3e73584ef1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts +++ b/x-pack/plugins/lists/public/common/empty_value.ts @@ -5,8 +5,4 @@ * 2.0. */ -export interface TrustedAppsUrlParams { - page_index: number; - page_size: number; - show?: 'create'; -} +export const getEmptyValue = (): string => '—'; diff --git a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts new file mode 100644 index 0000000000000..1516ca9128893 --- /dev/null +++ b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RecursivePartial } from '@elastic/eui/src/components/common'; + +import { EuiTheme } from '../../../../../../src/plugins/kibana_react/common'; + +export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => + partialTheme as EuiTheme; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx new file mode 100644 index 0000000000000..8272ca9683a4f --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Story, addDecorator } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { AndOrBadge, AndOrBadgeProps } from '.'; + +const sampleText = + 'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.'; + +const mockTheme = getMockTheme({ + darkMode: false, + eui: euiLightVars, +}); + +addDecorator((storyFn) => {storyFn()}); + +export default { + argTypes: { + includeAntennas: { + control: { + type: 'boolean', + }, + description: 'Determines whether extending vertical lines appear extended off of round badge', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + type: { + control: { + options: ['and', 'or'], + type: 'select', + }, + description: '`and | or` - determines text displayed in badge.', + table: { + defaultValue: { + summary: 'and', + }, + }, + type: { + required: true, + }, + }, + }, + component: AndOrBadge, + title: 'AndOrBadge', +}; + +const AndOrBadgeTemplate: Story = (args) => ( + + + + + +

{sampleText}

+
+
+); + +export const Default = AndOrBadgeTemplate.bind({}); +Default.args = { + includeAntennas: false, + type: 'and', +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx new file mode 100644 index 0000000000000..47282d061a65d --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { AndOrBadge } from './'; + +const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); + +describe('AndOrBadge', () => { + test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy(); + }); + + test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeFalsy(); + }); + + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx new file mode 100644 index 0000000000000..a7cbe66c16935 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { RoundedBadge } from './rounded_badge'; +import { RoundedBadgeAntenna } from './rounded_badge_antenna'; + +export type AndOr = 'and' | 'or'; +export interface AndOrBadgeProps { + type: AndOr; + includeAntennas?: boolean; +} +/** Displays AND / OR in a round badge */ +// This ticket is closed, however, as of 3/23/21 no round badge yet +// Ref: https://github.com/elastic/eui/issues/1655 +export const AndOrBadge = React.memo(({ type, includeAntennas = false }) => { + return includeAntennas ? : ; +}); + +AndOrBadge.displayName = 'AndOrBadge'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx new file mode 100644 index 0000000000000..489d02990b1f4 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { RoundedBadge } from './rounded_badge'; + +describe('RoundedBadge', () => { + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx new file mode 100644 index 0000000000000..0e8a8ee823593 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +import { AndOr } from '.'; + +const RoundBadge = (styled(EuiBadge)` + align-items: center; + border-radius: 100%; + display: inline-flex; + font-size: 9px; + height: 34px; + justify-content: center; + margin: 0 5px 0 5px; + padding: 7px 6px 4px 6px; + user-select: none; + width: 34px; + .euiBadge__content { + position: relative; + top: -1px; + } + .euiBadge__text { + text-overflow: clip; + } +` as unknown) as typeof EuiBadge; + +RoundBadge.displayName = 'RoundBadge'; + +export const RoundedBadge: React.FC<{ type: AndOr }> = ({ type }) => ( + + {type === 'and' ? i18n.AND : i18n.OR} + +); + +RoundedBadge.displayName = 'RoundedBadge'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx new file mode 100644 index 0000000000000..472345b9c9f19 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { RoundedBadgeAntenna } from './rounded_badge_antenna'; + +const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); + +describe('RoundedBadgeAntenna', () => { + test('it renders top and bottom antenna bars', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy(); + }); + + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx new file mode 100644 index 0000000000000..3e9d850db33b7 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { RoundedBadge } from './rounded_badge'; + +import { AndOr } from '.'; + +const antennaStyles = css` + background: ${({ theme }): string => theme.eui.euiColorLightShade}; + position: relative; + width: 2px; + &:after { + background: ${({ theme }): string => theme.eui.euiColorLightShade}; + content: ''; + height: 8px; + right: -4px; + position: absolute; + width: 10px; + clip-path: circle(); + } +`; + +const TopAntenna = styled(EuiFlexItem)` + ${antennaStyles} + &:after { + top: 0; + } +`; +const BottomAntenna = styled(EuiFlexItem)` + ${antennaStyles} + &:after { + bottom: 0; + } +`; + +export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => ( + + + + + + + +); + +RoundedBadgeAntenna.displayName = 'RoundedBadgeAntenna'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts new file mode 100644 index 0000000000000..0a0a46b224db1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AND = i18n.translate('xpack.lists.andOrBadge.andLabel', { + defaultMessage: 'AND', +}); + +export const OR = i18n.translate('xpack.lists.andOrBadge.orLabel', { + defaultMessage: 'OR', +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md b/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md new file mode 100644 index 0000000000000..fb500ca0761e3 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md @@ -0,0 +1,122 @@ +# Autocomplete Fields + +Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. + +All three of the available components rely on Eui's combo box. + +## useFieldValueAutocomplete + +This hook uses the kibana `services.data.autocomplete.getValueSuggestions()` service to return possible autocomplete fields based on the passed in `indexPattern` and `selectedField`. + +## FieldComponent + +This component can be used to display available indexPattern fields. It requires an indexPattern to be passed in and will show an error state if value is not one of the available indexPattern fields. Users will be able to select only one option. + +The `onChange` handler is passed `IFieldType[]`. + +```js + +``` + +## OperatorComponent + +This component can be used to display available operators. If you want to pass in your own operators, you can use `operatorOptions` prop. If a `operatorOptions` is provided, those will be used and it will ignore any of the built in logic that determines which operators to show. The operators within `operatorOptions` will still need to be of type `OperatorOption`. + +If no `operatorOptions` is provided, then the following behavior is observed: + +- if `selectedField` type is `boolean`, only `is`, `is not`, `exists`, `does not exist` operators will show +- if `selectedField` type is `nested`, only `is` operator will show +- if not one of the above, all operators will show (see `operators.ts`) + +The `onChange` handler is passed `OperatorOption[]`. + +```js + +``` + +## AutocompleteFieldExistsComponent + +This field value component is used when the selected operator is `exists` or `does not exist`. When these operators are selected, they are equivalent to using a wildcard. The combo box will be displayed as disabled. + +```js + +``` + +## AutocompleteFieldListsComponent + +This component can be used to display available large value lists - when operator selected is `is in list` or `is not in list`. It relies on hooks from the `lists` plugin. Users can only select one list and an error is shown if value is not one of available lists. + +The `selectedValue` should be the `id` of the selected list. + +This component relies on `selectedField` to render available lists. The reason being that it relies on the `selectedField` type to determine which lists to show as each large value list has a type as well. So if a user selects a field of type `ip`, it will only display lists of type `ip`. + +The `onChange` handler is passed `ListSchema`. + +```js + +``` + +## AutocompleteFieldMatchComponent + +This component can be used to allow users to select one single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. + +It does some minor validation, assuring that field value is a date if `selectedField` type is `date`, a number if `selectedField` type is `number`, an ip if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string`. + +```js + +``` + +## AutocompleteFieldMatchAnyComponent + +This component can be used to allow users to select multiple values. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own values. + +It does some minor validation, assuring that field values are a date if `selectedField` type is `date`, numbers if `selectedField` type is `number`, ips if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string[]`. + +```js + +``` \ No newline at end of file diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx new file mode 100644 index 0000000000000..416852b469a79 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { FieldComponent } from './field'; + +describe('FieldComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + + ); + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="fieldAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected field', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('machine.os.raw'); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'machine.os' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { + aggregatable: true, + count: 0, + esTypes: ['text'], + name: 'machine.os', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + ]); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx new file mode 100644 index 0000000000000..b3a5e36f12e40 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +import { getGenericComboBoxProps } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; + +const AS_PLAIN_TEXT = { asPlainText: true }; + +interface OperatorProps { + fieldInputWidth?: number; + fieldTypeFilter?: string[]; + indexPattern: IIndexPattern | undefined; + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + isRequired?: boolean; + onChange: (a: IFieldType[]) => void; + placeholder: string; + selectedField: IFieldType | undefined; +} + +export const FieldComponent: React.FC = ({ + fieldInputWidth, + fieldTypeFilter = [], + indexPattern, + isClearable = false, + isDisabled = false, + isLoading = false, + isRequired = false, + onChange, + placeholder, + selectedField, +}): JSX.Element => { + const [touched, setIsTouched] = useState(false); + + const { availableFields, selectedFields } = useMemo( + () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), + [indexPattern, selectedField, fieldTypeFilter] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + () => getComboBoxProps({ availableFields, selectedFields }), + [availableFields, selectedFields] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: IFieldType[] = newOptions.map( + ({ label }) => availableFields[labels.indexOf(label)] + ); + onChange(newValues); + }, + [availableFields, labels, onChange] + ); + + const handleTouch = useCallback((): void => { + setIsTouched(true); + }, [setIsTouched]); + + const fieldWidth = useMemo(() => { + return fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}; + }, [fieldInputWidth]); + + return ( + + ); +}; + +FieldComponent.displayName = 'Field'; + +interface ComboBoxFields { + availableFields: IFieldType[]; + selectedFields: IFieldType[]; +} + +const getComboBoxFields = ( + indexPattern: IIndexPattern | undefined, + selectedField: IFieldType | undefined, + fieldTypeFilter: string[] +): ComboBoxFields => { + const existingFields = getExistingFields(indexPattern); + const selectedFields = getSelectedFields(selectedField); + const availableFields = getAvailableFields(existingFields, selectedFields, fieldTypeFilter); + + return { availableFields, selectedFields }; +}; + +const getComboBoxProps = (fields: ComboBoxFields): GetGenericComboBoxPropsReturn => { + const { availableFields, selectedFields } = fields; + + return getGenericComboBoxProps({ + getLabel: (field) => field.name, + options: availableFields, + selectedOptions: selectedFields, + }); +}; + +const getExistingFields = (indexPattern: IIndexPattern | undefined): IFieldType[] => { + return indexPattern != null ? indexPattern.fields : []; +}; + +const getSelectedFields = (selectedField: IFieldType | undefined): IFieldType[] => { + return selectedField ? [selectedField] : []; +}; + +const getAvailableFields = ( + existingFields: IFieldType[], + selectedFields: IFieldType[], + fieldTypeFilter: string[] +): IFieldType[] => { + const fieldsByName = new Map(); + + existingFields.forEach((f) => fieldsByName.set(f.name, f)); + selectedFields.forEach((f) => fieldsByName.set(f.name, f)); + + const uniqueFields = Array.from(fieldsByName.values()); + + if (fieldTypeFilter.length > 0) { + return uniqueFields.filter(({ type }) => fieldTypeFilter.includes(type)); + } + + return uniqueFields; +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx new file mode 100644 index 0000000000000..b6300581f12dd --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { AutocompleteFieldExistsComponent } from './field_value_exists'; + +describe('AutocompleteFieldExistsComponent', () => { + test('it renders field disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox existsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx new file mode 100644 index 0000000000000..ff70204e53483 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + +const NO_OPTIONS_FOR_EXIST: EuiComboBoxOptionOption[] = []; + +interface AutocompleteFieldExistsProps { + placeholder: string; + rowLabel?: string; +} + +export const AutocompleteFieldExistsComponent: React.FC = ({ + placeholder, + rowLabel, +}): JSX.Element => ( + + + +); + +AutocompleteFieldExistsComponent.displayName = 'AutocompleteFieldExists'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx new file mode 100644 index 0000000000000..a5588b36aae03 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; + +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { ListSchema } from '../../../../common'; +import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { DATE_NOW, IMMUTABLE, VERSION } from '../../../../../lists/common/constants.mock'; + +import { AutocompleteFieldListsComponent } from './field_value_lists'; + +const mockKibanaHttpService = coreMock.createStart().http; + +const mockStart = jest.fn(); +const mockKeywordList: ListSchema = { + ...getListResponseMock(), + id: 'keyword_list', + name: 'keyword list', + type: 'keyword', +}; +const mockResult = { ...getFoundListSchemaMock() }; +mockResult.data = [...mockResult.data, mockKeywordList]; +jest.mock('../../..', () => { + const originalModule = jest.requireActual('../../..'); + + return { + ...originalModule, + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + useFindLists: () => ({ + error: undefined, + loading: false, + result: mockResult, + start: mockStart.mockReturnValue(mockResult), + }), + }; +}); + +describe('AutocompleteFieldListsComponent', () => { + test('it renders disabled if "isDisabled" is true', async () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', async () => { + const wrapper = mount( + + ); + + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', async () => { + const wrapper = mount( + + ); + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); + }); + + test('it correctly displays lists that match the selected "keyword" field esType', () => { + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'keyword list' }]); + }); + + test('it correctly displays lists that match the selected "ip" field esType', () => { + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); + }); + + test('it correctly displays selected list', async () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] EuiComboBoxPill`) + .at(0) + .text() + ).toEqual('some name'); + }); + + test('it invokes "onChange" when option selected', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'some name' }]); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ + _version: undefined, + created_at: DATE_NOW, + created_by: 'some user', + description: 'some description', + deserializer: undefined, + id: 'some-list-id', + immutable: IMMUTABLE, + meta: {}, + name: 'some name', + serializer: undefined, + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: DATE_NOW, + updated_by: 'some user', + version: VERSION, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx new file mode 100644 index 0000000000000..3d910403d4843 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { HttpStart } from 'kibana/public'; + +import { ListSchema } from '../../../../common'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { useFindLists } from '../../..'; + +import { filterFieldToList, getGenericComboBoxProps } from './helpers'; +import * as i18n from './translations'; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldListsProps { + httpService: HttpStart; + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + onChange: (arg: ListSchema) => void; + placeholder: string; + rowLabel?: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; +} + +export const AutocompleteFieldListsComponent: React.FC = ({ + httpService, + isClearable = false, + isDisabled = false, + isLoading = false, + onChange, + placeholder, + rowLabel, + selectedField, + selectedValue, +}): JSX.Element => { + const [error, setError] = useState(undefined); + const [lists, setLists] = useState([]); + const { loading, result, start } = useFindLists(); + const getLabel = useCallback(({ name }) => name, []); + + const optionsMemo = useMemo(() => filterFieldToList(lists, selectedField), [ + lists, + selectedField, + ]); + const selectedOptionsMemo = useMemo(() => { + if (selectedValue != null) { + const list = lists.filter(({ id }) => id === selectedValue); + return list ?? []; + } else { + return []; + } + }, [selectedValue, lists]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + () => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + onChange(newValue ?? ''); + }, + [labels, optionsMemo, onChange] + ); + + const setIsTouchedValue = useCallback((): void => { + setError(selectedValue == null ? i18n.FIELD_REQUIRED_ERR : undefined); + }, [selectedValue]); + + useEffect(() => { + if (result != null) { + setLists(result.data); + } + }, [result]); + + useEffect(() => { + if (selectedField != null && httpService != null) { + start({ + http: httpService, + pageIndex: 1, + pageSize: 500, + }); + } + }, [selectedField, start, httpService]); + + const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]); + + return ( + + + + ); +}; + +AutocompleteFieldListsComponent.displayName = 'AutocompleteFieldList'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx new file mode 100644 index 0000000000000..c1ffb008e8563 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx @@ -0,0 +1,443 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiSuperSelect } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { AutocompleteFieldMatchComponent } from './field_value_match'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; + +jest.mock('./hooks/use_field_value_autocomplete'); + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +describe('AutocompleteFieldMatchComponent', () => { + let wrapper: ReactWrapper; + + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders row label if one passed in', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchLabel"] label').at(0).text() + ).toEqual('Row Label'); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] input').prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click'); + expect( + wrapper + .find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatch-optionsList"]') + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="comboBoxInput"]') + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] EuiComboBoxPill').at(0).text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith('126.45.211.34'); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith('value 1'); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + }); + + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: '', + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + operatorType: 'match', + query: 'value 1', + selectedField: getField('machine.os.raw'), + }); + }); + + describe('boolean type', () => { + const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + false, + [], + valueSuggestionsMock, + ]); + }); + + test('it displays only two options - "true" or "false"', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').at(0).prop('options') + ).toEqual([ + { + inputDisplay: 'true', + value: 'true', + }, + { + inputDisplay: 'false', + value: 'false', + }, + ]); + }); + + test('it invokes "onChange" with "true" when selected', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiSuperSelect).props() as unknown) as { + onChange: (a: string) => void; + }).onChange('true'); + + expect(mockOnChange).toHaveBeenCalledWith('true'); + }); + + test('it invokes "onChange" with "false" when selected', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiSuperSelect).props() as unknown) as { + onChange: (a: string) => void; + }).onChange('false'); + + expect(mockOnChange).toHaveBeenCalledWith('false'); + }); + }); + + describe('number type', () => { + const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + false, + [], + valueSuggestionsMock, + ]); + }); + + test('it number input when field type is number', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valueAutocompleteFieldMatchNumber"]').exists() + ).toBeTruthy(); + }); + + test('it invokes "onChange" with numeric value when inputted', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="valueAutocompleteFieldMatchNumber"] input') + .at(0) + .simulate('change', { target: { value: '8' } }); + + expect(mockOnChange).toHaveBeenCalledWith('8'); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx new file mode 100644 index 0000000000000..a0994871808d1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx @@ -0,0 +1,300 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useEffect, useMemo, useState } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldNumber, + EuiFormRow, + EuiSuperSelect, +} from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { OperatorTypeEnum } from '../../../../common'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; + +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +const BOOLEAN_OPTIONS = [ + { inputDisplay: 'true', value: 'true' }, + { inputDisplay: 'false', value: 'false' }, +]; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldMatchProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + isRequired?: boolean; + fieldInputWidth?: number; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string) => void; + onError?: (arg: boolean) => void; +} + +export const AutocompleteFieldMatchComponent: React.FC = ({ + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + fieldInputWidth, + onChange, + onError, + autocompleteService, +}): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.MATCH, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue != null && selectedValue.trim() !== '' + ? uniq([valueAsStr, ...suggestions]) + : suggestions; + }, [suggestions, selectedValue]); + const selectedOptionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? [valueAsStr] : []; + }, [selectedValue]); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + onChange(newValue ?? ''); + }, + [handleError, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string): void => { + if (searchVal !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched] + ); + + const handleCreateOption = useCallback( + (option: string): boolean | undefined => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange(option); + return undefined; + } + }, + [isRequired, onChange, selectedField, touched, handleError] + ); + + const handleNonComboBoxInputChange = useCallback( + (event: React.ChangeEvent): void => { + const newValue = event.target.value; + onChange(newValue); + }, + [onChange] + ); + + const handleBooleanInputChange = useCallback( + (newOption: string): void => { + onChange(newOption); + }, + [onChange] + ); + + const setIsTouchedValue = useCallback((): void => { + setIsTouched(true); + + const err = paramIsValid(selectedValue, selectedField, isRequired, true); + handleError(err); + }, [setIsTouched, handleError, selectedValue, selectedField, isRequired]); + + const inputPlaceholder = useMemo((): string => { + if (isLoading || isLoadingSuggestions) { + return i18n.LOADING; + } else if (selectedField == null) { + return i18n.SELECT_FIELD_FIRST; + } else { + return placeholder; + } + }, [isLoading, selectedField, isLoadingSuggestions, placeholder]); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); + + const fieldInputWidths = useMemo( + () => (fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}), + [fieldInputWidth] + ); + + useEffect((): void => { + setError(undefined); + if (onError != null) { + onError(false); + } + }, [selectedField, onError]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + fieldInputWidths, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + ]); + + if (!isSuggestingValues && selectedField != null) { + switch (selectedField.type) { + case 'number': + return ( + + 0 + ? parseFloat(selectedValue) + : selectedValue ?? '' + } + onChange={handleNonComboBoxInputChange} + data-test-subj="valueAutocompleteFieldMatchNumber" + style={fieldInputWidths} + fullWidth + /> + + ); + case 'boolean': + return ( + + + + ); + default: + return defaultInput; + } + } else { + return defaultInput; + } +}; + +AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx new file mode 100644 index 0000000000000..8aa1f18b695a0 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; + +import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +jest.mock('./hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldMatchAnyComponent', () => { + let wrapper: ReactWrapper; + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatchAny-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] EuiComboBoxPill`).at(0).text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith(['126.45.211.34']); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith(['value 1']); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + }); + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: [], + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + operatorType: 'match_any', + query: 'value 1', + selectedField: getField('machine.os.raw'), + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx new file mode 100644 index 0000000000000..08958f6d99aab --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { OperatorTypeEnum } from '../../../../common'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; + +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +interface AutocompleteFieldMatchAnyProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string[]; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + isRequired?: boolean; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string[]) => void; + onError?: (arg: boolean) => void; +} + +export const AutocompleteFieldMatchAnyComponent: React.FC = ({ + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + onChange, + onError, + autocompleteService, +}): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.MATCH_ANY, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo( + (): string[] => (selectedValue ? uniq([...selectedValue, ...suggestions]) : suggestions), + [suggestions, selectedValue] + ); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedValue, + }), + [optionsMemo, selectedValue, getLabel] + ); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: string[] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + onChange(newValues); + }, + [handleError, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string) => { + if (searchVal === '') { + handleError(undefined); + } + + if (searchVal !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched] + ); + + const handleCreateOption = useCallback( + (option: string): boolean => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange([...(selectedValue || []), option]); + return true; + } + }, + [handleError, isRequired, onChange, selectedField, selectedValue, touched] + ); + + const setIsTouchedValue = useCallback((): void => { + handleError(selectedComboOptions.length === 0 ? i18n.FIELD_REQUIRED_ERR : undefined); + setIsTouched(true); + }, [setIsTouched, handleError, selectedComboOptions]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + ]); + + if (!isSuggestingValues && selectedField != null) { + switch (selectedField.type) { + case 'number': + return ( + + + + ); + default: + return defaultInput; + } + } + + return defaultInput; +}; + +AutocompleteFieldMatchAnyComponent.displayName = 'AutocompleteFieldMatchAny'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts new file mode 100644 index 0000000000000..2fed462974a26 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts @@ -0,0 +1,388 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../common'; + +import * as i18n from './translations'; +import { + EXCEPTION_OPERATORS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOperator, +} from './operators'; +import { + checkEmptyValue, + filterFieldToList, + getGenericComboBoxProps, + getOperators, + paramIsValid, + typeMatch, +} from './helpers'; + +describe('helpers', () => { + // @ts-ignore + moment.suppressDeprecationWarnings = true; + describe('#getOperators', () => { + test('it returns "isOperator" if passed in field is "undefined"', () => { + const operator = getOperators(undefined); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns expected operators when field type is "boolean"', () => { + const operator = getOperators(getField('ssl')); + + expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]); + }); + + test('it returns "isOperator" when field type is "nested"', () => { + const operator = getOperators({ + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'nestedField' } }, + type: 'nested', + }); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns all operator types when field type is not null, boolean, or nested', () => { + const operator = getOperators(getField('machine.os.raw')); + + expect(operator).toEqual(EXCEPTION_OPERATORS); + }); + }); + + describe('#checkEmptyValue', () => { + test('returns no errors if no field has been selected', () => { + const isValid = checkEmptyValue('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = checkEmptyValue('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns null if input value is not empty string or undefined', () => { + const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true); + + expect(isValid).toBeNull(); + }); + }); + + describe('#paramIsValid', () => { + test('returns no errors if no field has been selected', () => { + const isValid = paramIsValid('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = paramIsValid('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type date and value is valid', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + false, + true + ); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if filed is of type date and value is not valid', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true); + + expect(isValid).toEqual(i18n.DATE_ERR); + }); + + test('returns no errors if field is of type number and value is an integer', () => { + const isValid = paramIsValid('4', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a float', () => { + const isValid = paramIsValid('4.3', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a long', () => { + const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if field is of type number and value is "hello"', () => { + const isValid = paramIsValid('hello', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); + + test('returns errors if field is of type number and value is "123abc"', () => { + const isValid = paramIsValid('123abc', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); + }); + + describe('#getGenericComboBoxProps', () => { + test('it returns empty arrays if "options" is empty array', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: [], + selectedOptions: ['option1'], + }); + + expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); + }); + + test('it returns formatted props if "options" array is not empty', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: [], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it does not return "selectedOptions" items that do not appear in "options"', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option4'], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it return "selectedOptions" items that do appear in "options"', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option2'], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [ + { + label: 'option2', + }, + ], + }); + }); + }); + + describe('#typeMatch', () => { + test('ip -> ip is true', () => { + expect(typeMatch('ip', 'ip')).toEqual(true); + }); + + test('keyword -> keyword is true', () => { + expect(typeMatch('keyword', 'keyword')).toEqual(true); + }); + + test('text -> text is true', () => { + expect(typeMatch('text', 'text')).toEqual(true); + }); + + test('ip_range -> ip is true', () => { + expect(typeMatch('ip_range', 'ip')).toEqual(true); + }); + + test('date_range -> date is true', () => { + expect(typeMatch('date_range', 'date')).toEqual(true); + }); + + test('double_range -> double is true', () => { + expect(typeMatch('double_range', 'double')).toEqual(true); + }); + + test('float_range -> float is true', () => { + expect(typeMatch('float_range', 'float')).toEqual(true); + }); + + test('integer_range -> integer is true', () => { + expect(typeMatch('integer_range', 'integer')).toEqual(true); + }); + + test('long_range -> long is true', () => { + expect(typeMatch('long_range', 'long')).toEqual(true); + }); + + test('ip -> date is false', () => { + expect(typeMatch('ip', 'date')).toEqual(false); + }); + + test('long -> float is false', () => { + expect(typeMatch('long', 'float')).toEqual(false); + }); + + test('integer -> long is false', () => { + expect(typeMatch('integer', 'long')).toEqual(false); + }); + }); + + describe('#filterFieldToList', () => { + test('it returns empty array if given a undefined for field', () => { + const filter = filterFieldToList([], undefined); + expect(filter).toEqual([]); + }); + + test('it returns empty array if filed does not contain esTypes', () => { + const field: IFieldType = { name: 'some-name', type: 'some-type' }; + const filter = filterFieldToList([], field); + expect(filter).toEqual([]); + }); + + test('it returns single filtered list of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of ip -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of keyword -> keyword', () => { + const field: IFieldType = { esTypes: ['keyword'], name: 'some-name', type: 'keyword' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of text -> text', () => { + const field: IFieldType = { esTypes: ['text'], name: 'some-name', type: 'text' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns 2 filtered lists of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1, listItem2]; + expect(filter).toEqual(expected); + }); + + test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1]; + expect(filter).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts new file mode 100644 index 0000000000000..4f25bec3b38dc --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import dateMath from '@elastic/datemath'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { ListSchema, Type } from '../../../../common'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; + +import { + EXCEPTION_OPERATORS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOperator, +} from './operators'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; +import * as i18n from './translations'; + +/** + * Returns the appropriate operators given a field type + * + * @param field IFieldType selected field + * + */ +export const getOperators = (field: IFieldType | undefined): OperatorOption[] => { + if (field == null) { + return [isOperator]; + } else if (field.type === 'boolean') { + return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; + } else if (field.type === 'nested') { + return [isOperator]; + } else { + return EXCEPTION_OPERATORS; + } +}; + +/** + * Determines if empty value is ok + * + * @param param the value being checked + * @param field the selected field + * @param isRequired whether or not an empty value is allowed + * @param touched has field been touched by user + * @returns undefined if valid, string with error message if invalid, + * null if no checks matched + */ +export const checkEmptyValue = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined | null => { + if (isRequired && touched && (param == null || param.trim() === '')) { + return i18n.FIELD_REQUIRED_ERR; + } + + if ( + field == null || + (isRequired && !touched) || + (!isRequired && (param == null || param === '')) + ) { + return undefined; + } + + return null; +}; + +/** + * Very basic validation for values + * + * @param param the value being checked + * @param field the selected field + * @param isRequired whether or not an empty value is allowed + * @param touched has field been touched by user + * @returns undefined if valid, string with error message if invalid + */ +export const paramIsValid = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined => { + if (field == null) { + return undefined; + } + + const emptyValueError = checkEmptyValue(param, field, isRequired, touched); + if (emptyValueError !== null) { + return emptyValueError; + } + + switch (field.type) { + case 'date': + const moment = dateMath.parse(param ?? ''); + const isDate = Boolean(moment && moment.isValid()); + return isDate ? undefined : i18n.DATE_ERR; + case 'number': + const isNum = param != null && param.trim() !== '' && !isNaN(+param); + return isNum ? undefined : i18n.NUMBER_ERR; + default: + return undefined; + } +}; + +/** + * Determines the options, selected values and option labels for EUI combo box + * + * @param options options user can select from + * @param selectedOptions user selection if any + * @param getLabel helper function to know which property to use for labels + */ +export const getGenericComboBoxProps = ({ + getLabel, + options, + selectedOptions, +}: { + getLabel: (value: T) => string; + options: T[]; + selectedOptions: T[]; +}): GetGenericComboBoxPropsReturn => { + const newLabels = options.map(getLabel); + const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); + const newSelectedComboOptions = selectedOptions + .map(getLabel) + .filter((option) => { + return newLabels.indexOf(option) !== -1; + }) + .map((option) => { + return newComboOptions[newLabels.indexOf(option)]; + }); + + return { + comboOptions: newComboOptions, + labels: newLabels, + selectedComboOptions: newSelectedComboOptions, + }; +}; + +/** + * Given an array of lists and optionally a field this will return all + * the lists that match against the field based on the types from the field + * @param lists The lists to match against the field + * @param field The field to check against the list to see if they are compatible + */ +export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { + if (field != null) { + const { esTypes = [] } = field; + return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType))); + } else { + return []; + } +}; + +/** + * Given an input list type and a string based ES type this will match + * if they're exact or if they are compatible with a range + * @param type The type to match against the esType + * @param esType The ES type to match with + */ +export const typeMatch = (type: Type, esType: string): boolean => { + return ( + type === esType || + (type === 'ip_range' && esType === 'ip') || + (type === 'date_range' && esType === 'date') || + (type === 'double_range' && esType === 'double') || + (type === 'float_range' && esType === 'float') || + (type === 'integer_range' && esType === 'integer') || + (type === 'long_range' && esType === 'long') + ); +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts new file mode 100644 index 0000000000000..4e3fb2179d786 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, renderHook } from '@testing-library/react-hooks'; + +import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub'; +import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { OperatorTypeEnum } from '../../../../../common'; +import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; + +import { + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn, + useFieldValueAutocomplete, +} from './use_field_value_autocomplete'; + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +jest.mock('../../../../../../../../src/plugins/kibana_react/public'); + +describe('useFieldValueAutocomplete', () => { + const onErrorMock = jest.fn(); + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + afterEach(() => { + onErrorMock.mockClear(); + getValueSuggestionsMock.mockClear(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: undefined, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: undefined, + }) + ); + await waitForNextUpdate(); + + expect(result.current).toEqual([false, true, [], result.current[3]]); + }); + }); + + test('does not call autocomplete service if "operatorType" is "exists"', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: getField('machine.os'), + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "selectedField" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: undefined, + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "indexPattern" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: undefined, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: getField('machine.os'), + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('it uses full path name for nested fields to fetch suggestions', async () => { + const suggestionsMock = jest.fn().mockResolvedValue([]); + + await act(async () => { + const { signal } = new AbortController(); + const { waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: suggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: { ...getField('nestedField.child'), name: 'child' }, + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(suggestionsMock).toHaveBeenCalledWith({ + field: { ...getField('nestedField.child'), name: 'nestedField.child' }, + indexPattern: { + fields: [ + { + aggregatable: true, + esTypes: ['integer'], + filterable: true, + name: 'response', + searchable: true, + type: 'number', + }, + ], + id: '1234', + title: 'logstash-*', + }, + query: '', + signal, + }); + }); + }); + + test('returns "isSuggestingValues" of false if field type is boolean', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('ssl'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns "isSuggestingValues" of false to note that autocomplete service is not in use if no autocomplete suggestions available', async () => { + const suggestionsMock = jest.fn().mockResolvedValue([]); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: suggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('bytes'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; + + expect(suggestionsMock).toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns suggestions', async () => { + await act(async () => { + const { signal } = new AbortController(); + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('@tags'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + true, + ['value 1', 'value 2'], + result.current[3], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledWith({ + field: getField('@tags'), + indexPattern: stubIndexPatternWithFields, + query: '', + signal, + }); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns new suggestions on subsequent calls', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('@tags'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current[3]).not.toBeNull(); + + // Added check for typescripts sake, if null, + // would not reach below logic as test would stop above + if (result.current[3] != null) { + result.current[3]({ + fieldSelected: getField('@tags'), + patterns: stubIndexPatternWithFields, + searchQuery: '', + value: 'hello', + }); + } + + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + true, + ['value 1', 'value 2'], + result.current[3], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2); + expect(result.current).toEqual(expectedResult); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts new file mode 100644 index 0000000000000..6c6198ac55a0f --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.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 { useEffect, useRef, useState } from 'react'; +import { debounce } from 'lodash'; + +import { AutocompleteStart } from '../../../../../../../../src/plugins/data/public'; +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { OperatorTypeEnum } from '../../../../../common'; + +interface FuncArgs { + fieldSelected: IFieldType | undefined; + patterns: IIndexPattern | undefined; + searchQuery: string; + value: string | string[] | undefined; +} + +type Func = (args: FuncArgs) => void; + +export type UseFieldValueAutocompleteReturn = [boolean, boolean, string[], Func | null]; + +export interface UseFieldValueAutocompleteProps { + autocompleteService: AutocompleteStart; + fieldValue: string | string[] | undefined; + indexPattern: IIndexPattern | undefined; + operatorType: OperatorTypeEnum; + query: string; + selectedField: IFieldType | undefined; +} +/** + * Hook for using the field value autocomplete service + * + */ +export const useFieldValueAutocomplete = ({ + selectedField, + operatorType, + fieldValue, + query, + indexPattern, + autocompleteService, +}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => { + const [isLoading, setIsLoading] = useState(false); + const [isSuggestingValues, setIsSuggestingValues] = useState(true); + const [suggestions, setSuggestions] = useState([]); + const updateSuggestions = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchSuggestions = debounce( + async ({ fieldSelected, patterns, searchQuery }: FuncArgs) => { + try { + if (isSubscribed) { + if (fieldSelected == null || patterns == null) { + return; + } + + if (fieldSelected.type === 'boolean') { + setIsSuggestingValues(false); + return; + } + + setIsLoading(true); + + const field = + fieldSelected.subType != null && fieldSelected.subType.nested != null + ? { + ...fieldSelected, + name: `${fieldSelected.subType.nested.path}.${fieldSelected.name}`, + } + : fieldSelected; + + const newSuggestions = await autocompleteService.getValueSuggestions({ + field, + indexPattern: patterns, + query: searchQuery, + signal: abortCtrl.signal, + }); + + if (newSuggestions.length === 0) { + setIsSuggestingValues(false); + } + + setIsLoading(false); + setSuggestions([...newSuggestions]); + } + } catch (error) { + if (isSubscribed) { + setSuggestions([]); + setIsLoading(false); + } + } + }, + 500 + ); + + if (operatorType !== OperatorTypeEnum.EXISTS) { + fetchSuggestions({ + fieldSelected: selectedField, + patterns: indexPattern, + searchQuery: query, + value: fieldValue, + }); + } + + updateSuggestions.current = fetchSuggestions; + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [selectedField, operatorType, fieldValue, indexPattern, query, autocompleteService]); + + return [isLoading, isSuggestingValues, suggestions, updateSuggestions.current]; +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx new file mode 100644 index 0000000000000..1623683f25ed5 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AutocompleteFieldExistsComponent } from './field_value_exists'; +export { AutocompleteFieldListsComponent } from './field_value_lists'; +export { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; +export { AutocompleteFieldMatchComponent } from './field_value_match'; +export { FieldComponent } from './field'; +export { OperatorComponent } from './operator'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx new file mode 100644 index 0000000000000..1d033272197ca --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { OperatorComponent } from './operator'; +import { isNotOperator, isOperator } from './operators'; + +describe('OperatorComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + + ); + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="operatorAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); + }); + + test('it displays "operatorOptions" if param is passed in with items', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is not' }]); + }); + + test('it does not display "operatorOptions" if param is passed in with no items', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { + label: 'is', + }, + { + label: 'is not', + }, + { + label: 'is one of', + }, + { + label: 'is not one of', + }, + { + label: 'exists', + }, + { + label: 'does not exist', + }, + { + label: 'is in list', + }, + { + label: 'is not in list', + }, + ]); + }); + + test('it correctly displays selected operator', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('is'); + }); + + test('it only displays subset of operators if field type is nested', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is' }]); + }); + + test('it only displays subset of operators if field type is boolean', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { label: 'is' }, + { label: 'is not' }, + { label: 'exists' }, + { label: 'does not exist' }, + ]); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'is not' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { message: 'is not', operator: 'excluded', type: 'match', value: 'is_not' }, + ]); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx new file mode 100644 index 0000000000000..7fc221c5a097c --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; + +import { getGenericComboBoxProps, getOperators } from './helpers'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; + +const AS_PLAIN_TEXT = { asPlainText: true }; + +interface OperatorState { + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + onChange: (arg: OperatorOption[]) => void; + operator: OperatorOption; + operatorInputWidth?: number; + operatorOptions?: OperatorOption[]; + placeholder: string; + selectedField: IFieldType | undefined; +} + +export const OperatorComponent: React.FC = ({ + isClearable = false, + isDisabled = false, + isLoading = false, + onChange, + operator, + operatorOptions, + operatorInputWidth = 150, + placeholder, + selectedField, +}): JSX.Element => { + const getLabel = useCallback(({ message }): string => message, []); + const optionsMemo = useMemo( + (): OperatorOption[] => + operatorOptions != null && operatorOptions.length > 0 + ? operatorOptions + : getOperators(selectedField), + [operatorOptions, selectedField] + ); + const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [ + operator, + ]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: OperatorOption[] = newOptions.map( + ({ label }) => optionsMemo[labels.indexOf(label)] + ); + onChange(newValues); + }, + [labels, onChange, optionsMemo] + ); + + const inputWidth = useMemo(() => { + return { width: `${operatorInputWidth}px` }; + }, [operatorInputWidth]); + + return ( + + ); +}; + +OperatorComponent.displayName = 'Operator'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts new file mode 100644 index 0000000000000..551dfcb61e3ad --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; + +import { OperatorOption } from './types'; + +export const isOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isOperatorLabel', { + defaultMessage: 'is', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is', +}; + +export const isNotOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotOperatorLabel', { + defaultMessage: 'is not', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is_not', +}; + +export const isOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isOneOfOperatorLabel', { + defaultMessage: 'is one of', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: 'is_one_of', +}; + +export const isNotOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotOneOfOperatorLabel', { + defaultMessage: 'is not one of', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: 'is_not_one_of', +}; + +export const existsOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.existsOperatorLabel', { + defaultMessage: 'exists', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.EXISTS, + value: 'exists', +}; + +export const doesNotExistOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.doesNotExistOperatorLabel', { + defaultMessage: 'does not exist', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.EXISTS, + value: 'does_not_exist', +}; + +export const isInListOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isInListOperatorLabel', { + defaultMessage: 'is in list', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.LIST, + value: 'is_in_list', +}; + +export const isNotInListOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotInListOperatorLabel', { + defaultMessage: 'is not in list', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.LIST, + value: 'is_not_in_list', +}; + +export const EXCEPTION_OPERATORS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, + isInListOperator, + isNotInListOperator, +]; + +export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, +]; + +export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [ + isInListOperator, + isNotInListOperator, +]; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts new file mode 100644 index 0000000000000..065239246d329 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOADING = i18n.translate('xpack.lists.autocomplete.loadingDescription', { + defaultMessage: 'Loading...', +}); + +export const SELECT_FIELD_FIRST = i18n.translate('xpack.lists.autocomplete.selectField', { + defaultMessage: 'Please select a field first...', +}); + +export const FIELD_REQUIRED_ERR = i18n.translate('xpack.lists.autocomplete.fieldRequiredError', { + defaultMessage: 'Value cannot be empty', +}); + +export const NUMBER_ERR = i18n.translate('xpack.lists.autocomplete.invalidNumberError', { + defaultMessage: 'Not a valid number', +}); + +export const DATE_ERR = i18n.translate('xpack.lists.autocomplete.invalidDateError', { + defaultMessage: 'Not a valid date', +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts new file mode 100644 index 0000000000000..8ea3e8d927d68 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; + +export interface GetGenericComboBoxPropsReturn { + comboOptions: EuiComboBoxOptionOption[]; + labels: string[]; + selectedComboOptions: EuiComboBoxOptionOption[]; +} + +export interface OperatorOption { + message: string; + value: string; + operator: OperatorEnum; + type: OperatorTypeEnum; +} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx index 16678e4da2a1d..dc773e222776b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + import { BuilderAndBadgeComponent } from './and_badge'; -import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx index fd561110d885f..6f867d772072f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { AndOrBadge } from '../../and_or_badge'; +import { AndOrBadge } from '../and_or_badge'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx index d86e668a93ad6..9ed8b2c41b4ba 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx @@ -8,8 +8,8 @@ import { mount } from 'enzyme'; import React from 'react'; -import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; +import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx index 48bdeb4d5b044..01739bd3f85cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx @@ -9,7 +9,7 @@ import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { BuilderEntry } from '../types'; +import { BuilderEntry } from './types'; const MyFirstRowContainer = styled(EuiFlexItem)` padding-top: 20px; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx new file mode 100644 index 0000000000000..8408fb7a6a4f1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Story, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { HttpStart } from 'kibana/public'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { BuilderEntryItem, EntryItemProps } from './entry_renderer'; + +const mockTheme = getMockTheme({ + darkMode: false, + eui: euiLightVars, +}); +const mockAutocompleteService = ({ + getValueSuggestions: () => + new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + field: { + aggregatable: true, + count: 30, + esTypes: ['date'], + name: '@timestamp', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'date', + }, + type: 'field', + }, + { + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + type: 'field', + }, + ]); + }, 300); + }), +} as unknown) as AutocompleteStart; + +addDecorator((storyFn) => {storyFn()}); + +export default { + argTypes: { + allowLargeValueLists: { + control: { + type: 'boolean', + }, + description: '`boolean` - set to true to allow large value lists.', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + autoCompleteService: { + control: { + type: 'object', + }, + description: + '`AutocompleteStart` - Kibana data plugin autocomplete service used for field value autocomplete.', + type: { + required: true, + }, + }, + entry: { + control: { + type: 'object', + }, + description: '`FormattedBuilderEntry` - A single exception item entry.', + type: { + required: true, + }, + }, + httpService: { + control: { + type: 'object', + }, + description: '`HttpStart` - Kibana service.', + type: { + required: true, + }, + }, + indexPattern: { + description: + '`IIndexPattern` - index patterns used to populate field options and value autocomplete.', + type: { + required: true, + }, + }, + listType: { + control: { + options: ['detection', 'endpoint'], + type: 'select', + }, + description: + '`ExceptionListType` - Depending on the list type, certain validations may apply.', + type: { + required: true, + }, + }, + + onChange: { + description: + '`(arg: BuilderEntry, i: number) => void` - callback invoked any time field, operator or value is updated.', + type: { + required: true, + }, + }, + onlyShowListOperators: { + description: + '`boolean` - set to true to display to user only operators related to large value lists. This is currently used due to limitations around combining large value list exceptions and non-large value list exceptions.', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + setErrorsExist: { + description: '`(arg: boolean) => void` - callback invoked to bubble up input errors.', + type: { + required: true, + }, + }, + showLabel: { + description: + '`boolean` - whether or not to show the input labels (normally just wanted for the first entry item).', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + }, + component: BuilderEntryItem, + title: 'BuilderEntryItem', +}; + +const BuilderEntryItemTemplate: Story = (args) => ; + +export const Default = BuilderEntryItemTemplate.bind({}); +Default.args = { + autocompleteService: mockAutocompleteService, + + entry: { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: 'e37ad550-05d2-470e-9a95-487db201ab56', + nested: undefined, + operator: { + message: 'is', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is', + }, + parent: undefined, + value: '', + }, + httpService: {} as HttpStart, + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + listType: 'detection', + onChange: action('onClick'), + onlyShowListOperators: false, + setErrorsExist: action('onClick'), + showLabel: false, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 9c9035d7e66e9..8f6f9329f2974 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -8,47 +8,46 @@ import { ReactWrapper, mount } from 'enzyme'; import React from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; -import { BuilderEntryItem } from './entry_item'; import { - isOperator, - isNotOperator, - isOneOfOperator, - isNotOneOfOperator, + doesNotExistOperator, + existsOperator, isInListOperator, isNotInListOperator, - existsOperator, - doesNotExistOperator, -} from '../../autocomplete/operators'; + isNotOneOfOperator, + isNotOperator, + isOneOfOperator, + isOperator, +} from '../autocomplete/operators'; import { fields, getField, -} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getFoundListSchemaMock } from '../../../../../../lists/common/schemas/response/found_list_schema.mock'; -import { getEmptyValue } from '../../empty_value'; -import { waitFor } from '@testing-library/dom'; +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getFoundListSchemaMock } from '../../../../common/schemas/response/found_list_schema.mock'; +import { useFindLists } from '../../../lists/hooks/use_find_lists'; -// mock out lists hook -const mockStart = jest.fn(); -const mockResult = getFoundListSchemaMock(); -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../lists_plugin_deps', () => { - const originalModule = jest.requireActual('../../../../lists_plugin_deps'); +import { BuilderEntryItem } from './entry_renderer'; - return { - ...originalModule, - useFindLists: () => ({ - loading: false, - start: mockStart.mockReturnValue(mockResult), - result: mockResult, - error: undefined, - }), - }; -}); +jest.mock('../../../lists/hooks/use_find_lists'); + +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('BuilderEntryItem', () => { let wrapper: ReactWrapper; + beforeEach(() => { + (useFindLists as jest.Mock).mockReturnValue({ + error: undefined, + loading: false, + result: getFoundListSchemaMock(), + start: jest.fn(), + }); + }); + afterEach(() => { jest.clearAllMocks(); wrapper.unmount(); @@ -57,25 +56,27 @@ describe('BuilderEntryItem', () => { test('it renders field labels if "showLabel" is "true"', () => { wrapper = mount( ); @@ -85,25 +86,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isOperator"', () => { wrapper = mount( ); @@ -117,25 +120,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotOperator"', () => { wrapper = mount( ); @@ -151,25 +156,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isOneOfOperator"', () => { wrapper = mount( ); @@ -185,25 +192,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotOneOfOperator"', () => { wrapper = mount( ); @@ -219,25 +228,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isInListOperator"', () => { wrapper = mount( ); @@ -253,25 +264,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotInListOperator"', () => { wrapper = mount( ); @@ -287,25 +300,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "existsOperator"', () => { wrapper = mount( ); @@ -313,9 +328,7 @@ describe('BuilderEntryItem', () => { expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual( 'exists' ); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual( - getEmptyValue() - ); + expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—'); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled ).toBeTruthy(); @@ -324,25 +337,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "doesNotExistOperator"', () => { wrapper = mount( ); @@ -350,9 +365,7 @@ describe('BuilderEntryItem', () => { expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual( 'does not exist' ); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual( - getEmptyValue() - ); + expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—'); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled ).toBeTruthy(); @@ -361,57 +374,59 @@ describe('BuilderEntryItem', () => { test('it uses "correspondingKeywordField" if it exists', () => { wrapper = mount( ); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField') ).toEqual({ - name: 'extension', - type: 'string', - esTypes: ['keyword'], + aggregatable: true, count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, scripted: false, searchable: true, - aggregatable: true, - readFromDocValues: true, + type: 'string', }); }); @@ -419,25 +434,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -446,7 +463,7 @@ describe('BuilderEntryItem', () => { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' }, + { field: 'machine.os', id: '123', operator: 'included', type: 'match', value: '' }, 0 ); }); @@ -455,25 +472,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -482,7 +501,7 @@ describe('BuilderEntryItem', () => { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, + { field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -491,25 +510,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -518,7 +539,7 @@ describe('BuilderEntryItem', () => { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, + { field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '126.45.211.34' }, 0 ); }); @@ -527,25 +548,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -554,7 +577,7 @@ describe('BuilderEntryItem', () => { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, + { field: 'ip', id: '123', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, 0 ); }); @@ -563,25 +586,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -591,11 +616,11 @@ describe('BuilderEntryItem', () => { expect(mockOnChange).toHaveBeenCalledWith( { - id: '123', field: 'ip', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, operator: 'excluded', type: 'list', - list: { id: 'some-list-id', type: 'ip' }, }, 0 ); @@ -605,25 +630,27 @@ describe('BuilderEntryItem', () => { const mockSetErrorExists = jest.fn(); wrapper = mount( ); @@ -640,25 +667,27 @@ describe('BuilderEntryItem', () => { const mockSetErrorExists = jest.fn(); wrapper = mount( ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index af3b5362cbbf2..7c45f1c35c55e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -6,58 +6,65 @@ */ import React, { useCallback } from 'react'; -import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import styled from 'styled-components'; -import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils'; -import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { FieldComponent } from '../../autocomplete/field'; -import { OperatorComponent } from '../../autocomplete/operator'; -import { OperatorOption } from '../../autocomplete/types'; -import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match'; -import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any'; -import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists'; -import { FormattedBuilderEntry, BuilderEntry } from '../types'; -import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists'; -import { ListSchema, OperatorTypeEnum, ExceptionListType } from '../../../../lists_plugin_deps'; -import { getEmptyValue } from '../../empty_value'; -import * as i18n from './translations'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { HttpStart } from '../../../../../../../src/core/public'; +import { FieldComponent } from '../autocomplete/field'; +import { OperatorComponent } from '../autocomplete/operator'; +import { OperatorOption } from '../autocomplete/types'; +import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../autocomplete/operators'; +import { AutocompleteFieldExistsComponent } from '../autocomplete/field_value_exists'; +import { AutocompleteFieldMatchComponent } from '../autocomplete/field_value_match'; +import { AutocompleteFieldMatchAnyComponent } from '../autocomplete/field_value_match_any'; +import { AutocompleteFieldListsComponent } from '../autocomplete/field_value_lists'; +import { ExceptionListType, ListSchema, OperatorTypeEnum } from '../../../../common'; +import { getEmptyValue } from '../../../common/empty_value'; + import { - getFilteredIndexPatterns, - getOperatorOptions, getEntryOnFieldChange, - getEntryOnOperatorChange, - getEntryOnMatchChange, - getEntryOnMatchAnyChange, getEntryOnListChange, + getEntryOnMatchAnyChange, + getEntryOnMatchChange, + getEntryOnOperatorChange, + getFilteredIndexPatterns, + getOperatorOptions, } from './helpers'; -import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../../autocomplete/operators'; +import { BuilderEntry, FormattedBuilderEntry } from './types'; +import * as i18n from './translations'; const MyValuesInput = styled(EuiFlexItem)` overflow: hidden; `; -interface EntryItemProps { +export interface EntryItemProps { + allowLargeValueLists?: boolean; + autocompleteService: AutocompleteStart; entry: FormattedBuilderEntry; + httpService: HttpStart; indexPattern: IIndexPattern; - showLabel: boolean; listType: ExceptionListType; + listTypeSpecificFilter?: (pattern: IIndexPattern, type: ExceptionListType) => IIndexPattern; onChange: (arg: BuilderEntry, i: number) => void; - setErrorsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; - ruleType?: Type; + setErrorsExist: (arg: boolean) => void; + showLabel: boolean; } export const BuilderEntryItem: React.FC = ({ + allowLargeValueLists = false, + autocompleteService, entry, + httpService, indexPattern, listType, - showLabel, + listTypeSpecificFilter, onChange, - setErrorsExist, onlyShowListOperators = false, - ruleType, + setErrorsExist, + showLabel, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -112,7 +119,12 @@ export const BuilderEntryItem: React.FC = ({ const renderFieldInput = useCallback( (isFirst: boolean): JSX.Element => { - const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry, listType); + const filteredIndexPatterns = getFilteredIndexPatterns( + indexPattern, + entry, + listType, + listTypeSpecificFilter + ); const comboBox = ( = ({ ); } }, - [handleFieldChange, indexPattern, entry, listType] + [indexPattern, entry, listType, listTypeSpecificFilter, handleFieldChange] ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { @@ -155,7 +167,7 @@ export const BuilderEntryItem: React.FC = ({ entry, listType, entry.field != null && entry.field.type === 'boolean', - isFirst && !isEqlRule(ruleType) && !isThresholdRule(ruleType) + isFirst && !allowLargeValueLists ); const comboBox = ( = ({ const value = typeof entry.value === 'string' ? entry.value : undefined; return ( = ({ const values: string[] = Array.isArray(entry.value) ? entry.value : []; return ( = ({ const id = typeof entry.value === 'string' ? entry.value : undefined; return ( = ({ } isClearable={false} onChange={handleFieldListValueChange} - isRequired data-test-subj="exceptionBuilderEntryFieldList" /> ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index cbeb987f49b7b..0fd886bdc742a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -8,39 +8,28 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; -import { useKibana } from '../../../../common/lib/kibana'; -import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; -import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; +import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; -import { BuilderExceptionListItemComponent } from './exception_item'; -import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; +import { BuilderExceptionListItemComponent } from './exception_item_renderer'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece', }, }); - -jest.mock('../../../../common/lib/kibana'); +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('BuilderExceptionListItemComponent', () => { const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - afterEach(() => { getValueSuggestionsMock.mockClear(); }); @@ -54,20 +43,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -83,20 +74,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -110,20 +103,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -139,20 +134,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -175,20 +172,22 @@ describe('BuilderExceptionListItemComponent', () => { }; const wrapper = mount( ); @@ -203,20 +202,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -230,22 +231,24 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ); @@ -259,20 +262,22 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( ); @@ -288,20 +293,22 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()]; const wrapper = mount( ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index f9afa48408e39..d151ec5a81ec3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -5,22 +5,24 @@ * 2.0. */ -import React, { useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; +import { HttpStart } from 'kibana/public'; +import { AutocompleteStart } from 'src/plugins/data/public'; -import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; -import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; -import { ExceptionListType } from '../../../../../public/lists_plugin_deps'; -import { BuilderEntryItem } from './entry_item'; -import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; +import { ExceptionListType } from '../../../../common'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from './types'; import { BuilderAndBadgeComponent } from './and_badge'; +import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; +import { BuilderEntryItem } from './entry_renderer'; +import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; const MyBeautifulLine = styled(EuiFlexItem)` &:after { - background: ${({ theme }) => theme.eui.euiColorLightShade}; + background: ${({ theme }): string => theme.eui.euiColorLightShade}; content: ''; width: 2px; height: 40px; @@ -34,8 +36,10 @@ const MyOverflowContainer = styled(EuiFlexItem)` `; interface BuilderExceptionListItemProps { + allowLargeValueLists: boolean; + httpService: HttpStart; + autocompleteService: AutocompleteStart; exceptionItem: ExceptionsBuilderExceptionItem; - exceptionId: string; exceptionItemIndex: number; indexPattern: IIndexPattern; andLogicIncluded: boolean; @@ -45,13 +49,14 @@ interface BuilderExceptionListItemProps { onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; setErrorsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; - ruleType?: Type; } export const BuilderExceptionListItemComponent = React.memo( ({ + allowLargeValueLists, + httpService, + autocompleteService, exceptionItem, - exceptionId, exceptionItemIndex, indexPattern, isOnlyItem, @@ -61,7 +66,6 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -119,6 +123,9 @@ export const BuilderExceptionListItemComponent = React.memo} ({ + v4: jest.fn().mockReturnValue('123'), +})); + +const getEntryExistsWithIdMock = (): EntryExists & { id: string } => ({ + ...getEntryExistsMock(), + id: '123', +}); + +const getEntryNestedWithIdMock = (): EntryNested & { id: string } => ({ + ...getEntryNestedMock(), + id: '123', +}); + +const getEntryMatchWithIdMock = (): EntryMatch & { id: string } => ({ + ...getEntryMatchMock(), + id: '123', +}); + +const getEntryMatchAnyWithIdMock = (): EntryMatchAny & { id: string } => ({ + ...getEntryMatchAnyMock(), + id: '123', +}); + +const getMockIndexPattern = (): IIndexPattern => ({ + fields, + id: '1234', + title: 'logstash-*', +}); + +const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: getField('ip'), + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some value', +}); + +const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: getField('nestedField.child'), + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + value: 'some value', +}); + +const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: { ...getField('nestedField.child'), esTypes: ['nested'], name: 'nestedField' }, + id: '123', + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, +}); + +const mockEndpointFields = [ + { + aggregatable: false, + count: 0, + esTypes: ['keyword'], + name: 'file.path.caseless', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.Ext.code_signature.status', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'file.Ext.code_signature' } }, + type: 'string', + }, +]; + +export const getEndpointField = (name: string): IFieldType => + mockEndpointFields.find((field) => field.name === name) as IFieldType; + +const filterIndexPatterns = (patterns: IIndexPattern, type: ExceptionListType): IIndexPattern => { + return type === 'endpoint' + ? { + ...patterns, + fields: patterns.fields.filter(({ name }) => + ['file.path.caseless', 'file.Ext.code_signature.status'].includes(name) + ), + } + : patterns; +}; + +describe('Exception builder helpers', () => { + describe('#getFilteredIndexPatterns', () => { + describe('list type detections', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'child' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), esTypes: ['nested'], name: 'nestedField' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + + describe('list type endpoint', () => { + let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + + beforeAll(() => { + payloadIndexPattern = { + ...payloadIndexPattern, + fields: [...payloadIndexPattern.fields, ...mockEndpointFields], + }; + }); + + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: getEndpointField('file.Ext.code_signature.status'), + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'file.Ext.code_signature', + }, + parentIndex: 0, + }, + value: 'some value', + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: 'status' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: { + ...getEndpointField('file.Ext.code_signature.status'), + esTypes: ['nested'], + name: 'file.Ext.code_signature', + }, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['nested'], + name: 'file.Ext.code_signature', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'file.Ext.code_signature', + }, + }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns( + payloadIndexPattern, + payloadItem, + 'endpoint', + filterIndexPatterns + ); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns( + payloadIndexPattern, + payloadItem, + 'endpoint', + filterIndexPatterns + ); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['keyword'], + name: 'file.path.caseless', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.Ext.code_signature.status', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'file.Ext.code_signature' } }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + }); + + describe('#getEntryFromOperator', () => { + test('it returns current value when switching from "is" to "is not"', () => { + const payloadOperator: OperatorOption = isNotOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH, + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not" to "is"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is one of" to "is not one of"', () => { + const payloadOperator: OperatorOption = isNotOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH_ANY, + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not one of" to "is one of"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match_any"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "exists" to "does not exist"', () => { + const payloadOperator: OperatorOption = doesNotExistOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: existsOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "does not exist" to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: doesNotExistOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "list"', () => { + const payloadOperator: OperatorOption = isInListOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryList & { id?: string } = { + field: 'ip', + id: '123', + list: { id: '', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getOperatorOptions', () => { + test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if no field selected', () => { + const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', true); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns all operator options if "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = EXCEPTION_OPERATORS; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [ + isOperator, + isNotOperator, + existsOperator, + doesNotExistOperator, + ]; + expect(output).toEqual(expected); + }); + + test('it returns list operators if specified to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, true); + expect(output).toEqual(EXCEPTION_OPERATORS); + }); + + test('it does not return list operators if specified not to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, false); + expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); + }); + }); + + describe('#getEntryOnFieldChange', () => { + test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [ + { ...getEntryMatchWithIdMock(), field: 'child' }, + getEntryMatchAnyWithIdMock(), + ], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + getEntryMatchAnyWithIdMock(), + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns field of type "match" with updated field if not a nested entry', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadIFieldType: IFieldType = getField('ip'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnOperatorChange', () => { + test('it returns updated subentry preserving its value when entry is not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchAnyChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + field: undefined, + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + field: undefined, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnListChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + field: undefined, + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntries', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when no nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, + ]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + correspondingKeywordField: undefined, + entryIndex: 1, + field: { + aggregatable: true, + count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'string', + }, + id: '123', + nested: undefined, + operator: isOneOfOperator, + parent: undefined, + value: ['some extension'], + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadParent: EntryNested = { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }; + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...payloadParent }, + ]; + + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + correspondingKeywordField: undefined, + entryIndex: 1, + field: { + aggregatable: false, + esTypes: ['nested'], + name: 'nestedField', + searchable: false, + type: 'string', + }, + id: '123', + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }, + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some host name', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + parentIndex: 1, + }, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + }); + + describe('#getUpdatedEntriesOnDelete', () => { + test('it removes entry corresponding to "entryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes nested entry of "entryIndex" with corresponding parent index', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [], + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntry', () => { + test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { + const payloadIndexPattern: IIndexPattern = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'machine.os.raw.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: getField('machine.os.raw'), + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; + const payloadParent: EntryNested = { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + payloadParent, + 1 + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [{ ...payloadItem }], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + parentIndex: 1, + }, + value: 'some host name', + }; + expect(output).toEqual(expected); + }); + + test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'ip', + value: 'some ip', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#isEntryNested', () => { + test('it returns "false" if payload is not of type EntryNested', () => { + const payload: BuilderEntry = getEntryMatchWithIdMock(); + const output = isEntryNested(payload); + const expected = false; + expect(output).toEqual(expected); + }); + + test('it returns "true if payload is of type EntryNested', () => { + const payload: EntryNested = getEntryNestedWithIdMock(); + const output = isEntryNested(payload); + const expected = true; + expect(output).toEqual(expected); + }); + }); + + describe('#getCorrespondingKeywordField', () => { + test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw.text', + }); + + expect(output).toEqual(getField('machine.os.raw')); + }); + + test('it returns undefined if "selectedFieldIsTextType" is false', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is empty string', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: '', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is undefined', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: undefined, + }); + + expect(output).toEqual(undefined); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts new file mode 100644 index 0000000000000..b3ed9d296a218 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts @@ -0,0 +1,667 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { addIdToItem } from '../../../../common/shared_imports'; +import { + Entry, + EntryNested, + ExceptionListType, + ListSchema, + OperatorTypeEnum, + entriesList, +} from '../../../../common'; +import { + EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOneOfOperator, + isOperator, +} from '../autocomplete/operators'; +import { OperatorOption } from '../autocomplete/types'; + +import { + BuilderEntry, + EmptyNestedEntry, + ExceptionsBuilderExceptionItem, + FormattedBuilderEntry, +} from './types'; + +export const isEntryNested = (item: BuilderEntry): item is EntryNested => { + return (item as EntryNested).entries != null; +}; + +/** + * Returns the operator type, may not need this if using io-ts types + * + * @param item a single ExceptionItem entry + */ +export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { + switch (item.type) { + case 'match': + return OperatorTypeEnum.MATCH; + case 'match_any': + return OperatorTypeEnum.MATCH_ANY; + case 'list': + return OperatorTypeEnum.LIST; + default: + return OperatorTypeEnum.EXISTS; + } +}; + +/** + * Determines operator selection (is/is not/is one of, etc.) + * Default operator is "is" + * + * @param item a single ExceptionItem entry + */ +export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => { + if (item.type === 'nested') { + return isOperator; + } else { + const operatorType = getOperatorType(item); + const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { + return item.operator === operatorOption.operator && operatorType === operatorOption.type; + }); + + return foundOperator ?? isOperator; + } +}; + +/** + * Returns the fields corresponding value for an entry + * + * @param item a single ExceptionItem entry + */ +export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => { + switch (item.type) { + case OperatorTypeEnum.MATCH: + case OperatorTypeEnum.MATCH_ANY: + return item.value; + case OperatorTypeEnum.EXISTS: + return undefined; + case OperatorTypeEnum.LIST: + return item.list.id; + default: + return undefined; + } +}; + +/** + * Determines whether an entire entry, exception item, or entry within a nested + * entry needs to be removed + * + * @param exceptionItem + * @param entryIndex index of given entry, for nested entries, this will correspond + * to their parent index + * @param nestedEntryIndex index of nested entry + * + */ +export const getUpdatedEntriesOnDelete = ( + exceptionItem: ExceptionsBuilderExceptionItem, + entryIndex: number, + nestedParentIndex: number | null +): ExceptionsBuilderExceptionItem => { + const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; + + if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { + const updatedEntryEntries = [ + ...itemOfInterest.entries.slice(0, entryIndex), + ...itemOfInterest.entries.slice(entryIndex + 1), + ]; + + if (updatedEntryEntries.length === 0) { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, nestedParentIndex), + ...exceptionItem.entries.slice(nestedParentIndex + 1), + ], + }; + } else { + const { field } = itemOfInterest; + const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { + entries: updatedEntryEntries, + field, + id: itemOfInterest.id ?? `${entryIndex}`, + type: OperatorTypeEnum.NESTED, + }; + + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, nestedParentIndex), + updatedItemOfInterest, + ...exceptionItem.entries.slice(nestedParentIndex + 1), + ], + }; + } + } else { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } +}; + +/** + * Returns filtered index patterns based on the field - if a user selects to + * add nested entry, should only show nested fields, if item is the parent + * field of a nested entry, we only display the parent field + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * set to add a nested field + */ +export const getFilteredIndexPatterns = ( + patterns: IIndexPattern, + item: FormattedBuilderEntry, + type: ExceptionListType, + preFilter?: (i: IIndexPattern, t: ExceptionListType) => IIndexPattern +): IIndexPattern => { + const indexPatterns = preFilter != null ? preFilter(patterns, type) : patterns; + + if (item.nested === 'child' && item.parent != null) { + // when user has selected a nested entry, only fields with the common parent are shown + return { + ...indexPatterns, + fields: indexPatterns.fields + .filter((indexField) => { + const fieldHasCommonParentPath = + indexField.subType != null && + indexField.subType.nested != null && + item.parent != null && + indexField.subType.nested.path === item.parent.parent.field; + + return fieldHasCommonParentPath; + }) + .map((f) => { + const [fieldNameWithoutParentPath] = f.name.split('.').slice(-1); + return { ...f, name: fieldNameWithoutParentPath }; + }), + }; + } else if (item.nested === 'parent' && item.field != null) { + // when user has selected a nested entry, right above it we show the common parent + return { ...indexPatterns, fields: [item.field] }; + } else if (item.nested === 'parent' && item.field == null) { + // when user selects to add a nested entry, only nested fields are shown as options + return { + ...indexPatterns, + fields: indexPatterns.fields.filter( + (field) => field.subType != null && field.subType.nested != null + ), + }; + } else { + return indexPatterns; + } +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current exception item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnFieldChange = ( + item: FormattedBuilderEntry, + newField: IFieldType +): { index: number; updatedEntry: BuilderEntry } => { + const { parent, entryIndex, nested } = item; + const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; + + if (nested === 'parent') { + // For nested entries, when user first selects to add a nested + // entry, they first see a row similar to what is shown for when + // a user selects "exists", as soon as they make a selection + // we can now identify the 'parent' and 'child' this is where + // we first convert the entry into type "nested" + const newParentFieldValue = + newField.subType != null && newField.subType.nested != null + ? newField.subType.nested.path + : ''; + + return { + index: entryIndex, + updatedEntry: { + entries: [ + addIdToItem({ + field: newChildFieldValue ?? '', + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }), + ], + field: newParentFieldValue, + id: item.id, + type: OperatorTypeEnum.NESTED, + }, + }; + } else if (nested === 'child' && parent != null) { + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: newChildFieldValue ?? '', + id: item.id, + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: newField != null ? newField.name : '', + id: item.id, + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "list" + * + * @param item - current exception item entry values + * @param newField - newly selected list + * + */ +export const getEntryOnListChange = ( + item: FormattedBuilderEntry, + newField: ListSchema +): { index: number; updatedEntry: BuilderEntry } => { + const { entryIndex, field, operator } = item; + const { id, type } = newField; + + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + list: { id, type }, + operator: operator.operator, + type: OperatorTypeEnum.LIST, + }, + }; +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match_any" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchAnyChange = ( + item: FormattedBuilderEntry, + newField: string[] +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: newField, + }, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchChange = ( + item: FormattedBuilderEntry, + newField: string +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH, + value: newField, + }, + }; + } +}; + +/** + * On operator change, determines whether value needs to be cleared or not + * + * @param field + * @param selectedOperator + * @param currentEntry + * + */ +export const getEntryFromOperator = ( + selectedOperator: OperatorOption, + currentEntry: FormattedBuilderEntry +): Entry & { id?: string } => { + const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; + const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; + switch (selectedOperator.type) { + case 'match': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.MATCH, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; + case 'match_any': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], + }; + case 'list': + return { + field: fieldValue, + id: currentEntry.id, + list: { id: '', type: 'ip' }, + operator: selectedOperator.operator, + type: OperatorTypeEnum.LIST, + }; + default: + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.EXISTS, + }; + } +}; + +/** + * Determines proper entry update when user selects new operator + * + * @param item - current exception item entry values + * @param newOperator - newly selected operator + * + */ +export const getEntryOnOperatorChange = ( + item: FormattedBuilderEntry, + newOperator: OperatorOption +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, field, nested } = item; + const newEntry = getEntryFromOperator(newOperator, item); + + if (!entriesList.is(newEntry) && nested != null && parent != null) { + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + ...newEntry, + field: field != null ? field.name.split('.').slice(-1)[0] : '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { index: entryIndex, updatedEntry: newEntry }; + } +}; + +/** + * Determines which operators to make available + * + * @param item + * @param listType + * @param isBoolean + * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators + */ +export const getOperatorOptions = ( + item: FormattedBuilderEntry, + listType: ExceptionListType, + isBoolean: boolean, + includeValueListOperators = true +): OperatorOption[] => { + if (item.nested === 'parent' || item.field == null) { + return [isOperator]; + } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { + return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; + } else if (item.nested != null && listType === 'detection') { + return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; + } else { + return isBoolean + ? [isOperator, isNotOperator, existsOperator, doesNotExistOperator] + : includeValueListOperators + ? EXCEPTION_OPERATORS + : EXCEPTION_OPERATORS_SANS_LISTS; + } +}; + +/** + * Fields of type 'text' do not generate autocomplete values, we want + * to find it's corresponding keyword type (if available) which does + * generate autocomplete values + * + * @param fields IFieldType fields + * @param selectedField the field name that was selected + * @param isTextType we only want a corresponding keyword field if + * the selected field is of type 'text' + * + */ +export const getCorrespondingKeywordField = ({ + fields, + selectedField, +}: { + fields: IFieldType[]; + selectedField: string | undefined; +}): IFieldType | undefined => { + const selectedFieldBits = + selectedField != null && selectedField !== '' ? selectedField.split('.') : []; + const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; + + if (selectedFieldIsTextType && selectedFieldBits.length > 0) { + const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); + const [foundKeywordField] = fields.filter( + ({ name }) => keywordField !== '' && keywordField === name + ); + return foundKeywordField; + } + + return undefined; +}; + +/** + * Formats the entry into one that is easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param itemIndex entry index + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntry = ( + indexPattern: IIndexPattern, + item: BuilderEntry, + itemIndex: number, + parent: EntryNested | undefined, + parentIndex: number | undefined +): FormattedBuilderEntry => { + const { fields } = indexPattern; + const field = parent != null ? `${parent.field}.${item.field}` : item.field; + const [foundField] = fields.filter(({ name }) => field != null && field === name); + const correspondingKeywordField = getCorrespondingKeywordField({ + fields, + selectedField: field, + }); + + if (parent != null && parentIndex != null) { + return { + correspondingKeywordField, + entryIndex: itemIndex, + field: + foundField != null + ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } + : foundField, + id: item.id ?? `${itemIndex}`, + nested: 'child', + operator: getExceptionOperatorSelect(item), + parent: { parent, parentIndex }, + value: getEntryValue(item), + }; + } else { + return { + correspondingKeywordField, + entryIndex: itemIndex, + field: foundField, + id: item.id ?? `${itemIndex}`, + nested: undefined, + operator: getExceptionOperatorSelect(item), + parent: undefined, + value: getEntryValue(item), + }; + } +}; + +/** + * Formats the entries to be easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param entries exception item entries + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntries = ( + indexPattern: IIndexPattern, + entries: BuilderEntry[], + parent?: EntryNested, + parentIndex?: number +): FormattedBuilderEntry[] => { + return entries.reduce((acc, item, index) => { + const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; + if (item.type !== 'nested' && !isNewNestedEntry) { + const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( + indexPattern, + item, + index, + parent, + parentIndex + ); + return [...acc, newItemEntry]; + } else { + const parentEntry: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: index, + field: isNewNestedEntry + ? undefined + : { + aggregatable: false, + esTypes: ['nested'], + name: item.field ?? '', + searchable: false, + type: 'string', + }, + id: item.id ?? `${index}`, + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }; + + // User has selected to add a nested field, but not yet selected the field + if (isNewNestedEntry) { + return [...acc, parentEntry]; + } + + if (isEntryNested(item)) { + const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + + return [...acc, parentEntry, ...nestedItems]; + } + + return [...acc]; + } + }, []); +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts new file mode 100644 index 0000000000000..9da598c08bd83 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FIELD = i18n.translate('xpack.lists.exceptions.builder.fieldLabel', { + defaultMessage: 'Field', +}); + +export const OPERATOR = i18n.translate('xpack.lists.exceptions.builder.operatorLabel', { + defaultMessage: 'Operator', +}); + +export const VALUE = i18n.translate('xpack.lists.exceptions.builder.valueLabel', { + defaultMessage: 'Value', +}); + +export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldValuePlaceholder', + { + defaultMessage: 'Search field value...', + } +); + +export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldNestedPlaceholder', + { + defaultMessage: 'Search nested field', + } +); + +export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionListsPlaceholder', + { + defaultMessage: 'Search for list...', + } +); + +export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldPlaceholder', + { + defaultMessage: 'Search', + } +); + +export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionOperatorPlaceholder', + { + defaultMessage: 'Operator', + } +); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/types.ts b/x-pack/plugins/lists/public/exceptions/components/builder/types.ts new file mode 100644 index 0000000000000..cdb4f735aa103 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { OperatorOption } from '../autocomplete/types'; +import { + CreateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + ExceptionListItemSchema, + OperatorEnum, + OperatorTypeEnum, +} from '../../../../common'; + +export interface FormattedBuilderEntry { + id: string; + field: IFieldType | undefined; + operator: OperatorOption; + value: string | string[] | undefined; + nested: 'parent' | 'child' | undefined; + entryIndex: number; + parent: { parent: BuilderEntryNested; parentIndex: number } | undefined; + correspondingKeywordField: IFieldType | undefined; +} + +export interface EmptyEntry { + id: string; + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; + value: string | string[] | undefined; +} + +export interface EmptyListEntry { + id: string; + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.LIST; + list: { id: string | undefined; type: string | undefined }; +} + +export interface EmptyNestedEntry { + id: string; + field: string | undefined; + type: OperatorTypeEnum.NESTED; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +} + +export type BuilderEntry = + | (Entry & { id?: string }) + | EmptyListEntry + | EmptyEntry + | BuilderEntryNested + | EmptyNestedEntry; + +export type BuilderEntryNested = Omit & { + id?: string; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +}; + +export type ExceptionListItemBuilderSchema = Omit & { + entries: BuilderEntry[]; +}; + +export type CreateExceptionListItemBuilderSchema = Omit< + CreateExceptionListItemSchema, + 'meta' | 'entries' +> & { + meta: { temporaryUuid: string }; + entries: BuilderEntry[]; +}; + +export type ExceptionsBuilderExceptionItem = + | ExceptionListItemBuilderSchema + | CreateExceptionListItemBuilderSchema; diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index c9938897b5093..d35fe5bb06c0c 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -38,3 +38,7 @@ export { UseExceptionListItemsSuccess, UseExceptionListsSuccess, } from './exceptions/types'; +export { BuilderEntryItem } from './exceptions/components/builder/entry_renderer'; +export { BuilderAndBadgeComponent } from './exceptions/components/builder/and_badge'; +export { BuilderEntryDeleteButtonComponent } from './exceptions/components/builder/entry_delete_button'; +export { BuilderExceptionListItemComponent } from './exceptions/components/builder/exception_item_renderer'; diff --git a/x-pack/plugins/lists/scripts/storybook.js b/x-pack/plugins/lists/scripts/storybook.js new file mode 100644 index 0000000000000..9a15d01b66af1 --- /dev/null +++ b/x-pack/plugins/lists/scripts/storybook.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'lists', + storyGlobs: [join(__dirname, '..', 'public', '**', '*.stories.tsx')], +}); diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts index 1ebdf9f04bf9d..250b5e79ed109 100644 --- a/x-pack/plugins/lists/server/index.ts +++ b/x-pack/plugins/lists/server/index.ts @@ -12,7 +12,10 @@ import { ListPlugin } from './plugin'; // exporting these since its required at top level in siem plugin export { ListClient } from './services/lists/list_client'; -export { CreateExceptionListItemOptions } from './services/exception_lists/exception_list_client_types'; +export { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from './services/exception_lists/exception_list_client_types'; export { ExceptionListClient } from './services/exception_lists/exception_list_client'; export type { ListPluginSetup, ListsApiRequestHandlerContext } from './types'; diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.test.ts b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts index a13163d8f774a..acfed44c5259e 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts @@ -28,6 +28,7 @@ describe('crete_list_item', () => { const options = getCreateListItemOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.index.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const listItem = await createListItem({ ...options, esClient }); @@ -54,6 +55,7 @@ describe('crete_list_item', () => { options.id = undefined; const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.index.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const list = await createListItem({ ...options, esClient }); diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts index a5369bbfe7ca4..cf8a43be796df 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts @@ -6,7 +6,6 @@ */ import uuid from 'uuid'; -import { CreateDocumentResponse } from 'elasticsearch'; import { ElasticsearchClient } from 'kibana/server'; import { @@ -69,7 +68,7 @@ export const createListItem = async ({ ...baseBody, ...elasticQuery, }; - const { body: response } = await esClient.index({ + const { body: response } = await esClient.index({ body, id, index: listItemIndex, diff --git a/x-pack/plugins/lists/server/services/items/find_list_item.test.ts b/x-pack/plugins/lists/server/services/items/find_list_item.test.ts index 29e6f2f845002..c76d1c505df0c 100644 --- a/x-pack/plugins/lists/server/services/items/find_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/find_list_item.test.ts @@ -20,9 +20,11 @@ describe('find_list_item', () => { const options = getFindListItemOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.count.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ count: 1 }) ); esClient.search.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: '123', _shards: getShardMock(), diff --git a/x-pack/plugins/lists/server/services/items/find_list_item.ts b/x-pack/plugins/lists/server/services/items/find_list_item.ts index 727c55d53e459..3e37ccb0cfb1f 100644 --- a/x-pack/plugins/lists/server/services/items/find_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/find_list_item.ts @@ -6,7 +6,6 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Filter, @@ -75,8 +74,9 @@ export const findListItem = async ({ sortOrder, }); - const { body: respose } = await esClient.count<{ count: number }>({ + const { body: respose } = await esClient.count({ body: { + // @ts-expect-error GetQueryFilterReturn is not assignable to QueryContainer query, }, ignore_unavailable: true, @@ -87,8 +87,9 @@ export const findListItem = async ({ // Note: This typing of response = await esClient> // is because when you pass in seq_no_primary_term: true it does a "fall through" type and you have // to explicitly define the type . - const { body: response } = await esClient.search>({ + const { body: response } = await esClient.search({ body: { + // @ts-expect-error GetQueryFilterReturn is not assignable to QueryContainer query, search_after: scroll.searchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), diff --git a/x-pack/plugins/lists/server/services/items/get_list_item.ts b/x-pack/plugins/lists/server/services/items/get_list_item.ts index eb05a899478a5..519ebaedfddbc 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item.ts @@ -6,7 +6,6 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Id, ListItemSchema, SearchEsListItemSchema } from '../../../common/schemas'; import { transformElasticToListItem } from '../utils'; @@ -26,7 +25,7 @@ export const getListItem = async ({ // Note: This typing of response = await esClient> // is because when you pass in seq_no_primary_term: true it does a "fall through" type and you have // to explicitly define the type . - const { body: listItemES } = await esClient.search>({ + const { body: listItemES } = await esClient.search({ body: { query: { term: { @@ -40,6 +39,7 @@ export const getListItem = async ({ }); if (listItemES.hits.hits.length) { + // @ts-expect-error @elastic/elasticsearch _source is optional const type = findSourceType(listItemES.hits.hits[0]._source); if (type != null) { const listItems = transformElasticToListItem({ response: listItemES, type }); diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.test.ts b/x-pack/plugins/lists/server/services/items/update_list_item.test.ts index ae6b6ad3faecf..195bce879f34d 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.test.ts @@ -34,6 +34,7 @@ describe('update_list_item', () => { const options = getUpdateListItemOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.update.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const updatedList = await updateListItem({ ...options, esClient }); diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts index 645508691acc8..89c7e77707d8f 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { CreateDocumentResponse } from 'elasticsearch'; import { ElasticsearchClient } from 'kibana/server'; import { @@ -62,7 +61,7 @@ export const updateListItem = async ({ ...elasticQuery, }; - const { body: response } = await esClient.update({ + const { body: response } = await esClient.update({ ...decodeVersion(_version), body: { doc, diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts index b096adb2d1a13..ee4f3af9cdd5c 100644 --- a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts +++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.test.ts @@ -305,7 +305,9 @@ describe('write_list_items_to_stream', () => { test('it will throw an exception with a status code if the hit_source is not a data type we expect', () => { const options = getWriteResponseHitsToStreamOptionsMock(); + // @ts-expect-error _source is optional options.response.hits.hits[0]._source.ip = undefined; + // @ts-expect-error _source is optional options.response.hits.hits[0]._source.keyword = undefined; const expected = `Encountered an error where hit._source was an unexpected type: ${JSON.stringify( options.response.hits.hits[0]._source diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts index 9bdcb58835ab0..3679680ad79bd 100644 --- a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts +++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts @@ -7,7 +7,7 @@ import { PassThrough } from 'stream'; -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ElasticsearchClient } from 'kibana/server'; import { SearchEsListItemSchema } from '../../../common/schemas'; @@ -95,8 +95,9 @@ export const writeNextResponse = async ({ export const getSearchAfterFromResponse = ({ response, }: { - response: SearchResponse; + response: estypes.SearchResponse; }): string[] | undefined => + // @ts-expect-error @elastic/elasticsearch SortResults contains null response.hits.hits.length > 0 ? response.hits.hits[response.hits.hits.length - 1].sort : undefined; @@ -115,7 +116,7 @@ export const getResponse = async ({ listId, listItemIndex, size = SIZE, -}: GetResponseOptions): Promise> => { +}: GetResponseOptions): Promise> => { return (( await esClient.search({ body: { @@ -131,11 +132,11 @@ export const getResponse = async ({ index: listItemIndex, size, }) - ).body as unknown) as SearchResponse; + ).body as unknown) as estypes.SearchResponse; }; export interface WriteResponseHitsToStreamOptions { - response: SearchResponse; + response: estypes.SearchResponse; stream: PassThrough; stringToAppend: string | null | undefined; } @@ -148,6 +149,7 @@ export const writeResponseHitsToStream = ({ const stringToAppendOrEmpty = stringToAppend ?? ''; response.hits.hits.forEach((hit) => { + // @ts-expect-error @elastic/elasticsearch _source is optional const value = findSourceValue(hit._source); if (value != null) { stream.push(`${value}${stringToAppendOrEmpty}`); diff --git a/x-pack/plugins/lists/server/services/lists/create_list.test.ts b/x-pack/plugins/lists/server/services/lists/create_list.test.ts index 6fc556955fae3..e6213a1c6eabe 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.test.ts @@ -29,6 +29,7 @@ describe('crete_list', () => { const options = getCreateListOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.index.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const list = await createList({ ...options, esClient }); @@ -44,6 +45,7 @@ describe('crete_list', () => { }; const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.index.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const list = await createList({ ...options, esClient }); @@ -74,6 +76,7 @@ describe('crete_list', () => { options.id = undefined; const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.index.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const list = await createList({ ...options, esClient }); diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index 2671a23266ec9..baed699dc992f 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -6,7 +6,6 @@ */ import uuid from 'uuid'; -import { CreateDocumentResponse } from 'elasticsearch'; import { ElasticsearchClient } from 'kibana/server'; import { encodeHitVersion } from '../utils/encode_hit_version'; @@ -73,7 +72,7 @@ export const createList = async ({ updated_by: user, version, }; - const { body: response } = await esClient.index({ + const { body: response } = await esClient.index({ body, id, index: listIndex, diff --git a/x-pack/plugins/lists/server/services/lists/find_list.ts b/x-pack/plugins/lists/server/services/lists/find_list.ts index c5a398b0a1ad0..9c61d36dc0cd3 100644 --- a/x-pack/plugins/lists/server/services/lists/find_list.ts +++ b/x-pack/plugins/lists/server/services/lists/find_list.ts @@ -6,7 +6,6 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Filter, @@ -66,6 +65,7 @@ export const findList = async ({ const { body: totalCount } = await esClient.count({ body: { + // @ts-expect-error GetQueryFilterReturn is not compatible with QueryContainer query, }, ignore_unavailable: true, @@ -76,8 +76,9 @@ export const findList = async ({ // Note: This typing of response = await esClient> // is because when you pass in seq_no_primary_term: true it does a "fall through" type and you have // to explicitly define the type . - const { body: response } = await esClient.search>({ + const { body: response } = await esClient.search({ body: { + // @ts-expect-error GetQueryFilterReturn is not compatible with QueryContainer query, search_after: scroll.searchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), diff --git a/x-pack/plugins/lists/server/services/lists/get_list.ts b/x-pack/plugins/lists/server/services/lists/get_list.ts index 50e6d08dd80ff..6f18d143df00b 100644 --- a/x-pack/plugins/lists/server/services/lists/get_list.ts +++ b/x-pack/plugins/lists/server/services/lists/get_list.ts @@ -6,7 +6,6 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Id, ListSchema, SearchEsListSchema } from '../../../common/schemas'; import { transformElasticToList } from '../utils/transform_elastic_to_list'; @@ -25,7 +24,7 @@ export const getList = async ({ // Note: This typing of response = await esClient> // is because when you pass in seq_no_primary_term: true it does a "fall through" type and you have // to explicitly define the type . - const { body: response } = await esClient.search>({ + const { body: response } = await esClient.search({ body: { query: { term: { diff --git a/x-pack/plugins/lists/server/services/lists/update_list.test.ts b/x-pack/plugins/lists/server/services/lists/update_list.test.ts index e2d3b09fe518a..8cc1c60ecc23d 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.test.ts @@ -34,6 +34,7 @@ describe('update_list', () => { const options = getUpdateListOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.update.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const updatedList = await updateList({ ...options, esClient }); @@ -51,6 +52,7 @@ describe('update_list', () => { const options = getUpdateListOptionsMock(); const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; esClient.update.mockReturnValue( + // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ _id: 'elastic-id-123' }) ); const updatedList = await updateList({ ...options, esClient }); diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index aa4eb9a8d834f..f98e40b04b6d7 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { CreateDocumentResponse } from 'elasticsearch'; import { ElasticsearchClient } from 'kibana/server'; import { decodeVersion } from '../utils/decode_version'; @@ -61,7 +60,7 @@ export const updateList = async ({ updated_at: updatedAt, updated_by: user, }; - const { body: response } = await esClient.update({ + const { body: response } = await esClient.update({ ...decodeVersion(_version), body: { doc }, id, diff --git a/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts b/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts index 34359a7a9c697..ae37e47861845 100644 --- a/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts +++ b/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts @@ -6,7 +6,6 @@ */ import { ElasticsearchClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; import { Filter, SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; import { Scroll } from '../lists/types'; @@ -40,9 +39,10 @@ export const getSearchAfterScroll = async ({ const query = getQueryFilter({ filter }); let newSearchAfter = searchAfter; for (let i = 0; i < hops; ++i) { - const { body: response } = await esClient.search>>({ + const { body: response } = await esClient.search>({ body: { _source: getSourceWithTieBreaker({ sortField }), + // @ts-expect-error Filter is not assignale to QueryContainer query, search_after: newSearchAfter, sort: getSortWithTieBreaker({ sortField, sortOrder }), diff --git a/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts b/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts index 87749ed6fdb3b..3cd902aeeb36e 100644 --- a/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts +++ b/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { SortFieldOrUndefined } from '../../../common/schemas'; @@ -14,7 +13,7 @@ export type TieBreaker = T & { }; interface GetSearchAfterWithTieBreakerOptions { - response: SearchResponse>; + response: estypes.SearchResponse>; sortField: SortFieldOrUndefined; } @@ -27,14 +26,18 @@ export const getSearchAfterWithTieBreaker = ({ } else { const lastEsElement = response.hits.hits[response.hits.hits.length - 1]; if (sortField == null) { + // @ts-expect-error @elastic/elasticsearch _source is optional return [lastEsElement._source.tie_breaker_id]; } else { - const [[, sortValue]] = Object.entries(lastEsElement._source).filter( - ([key]) => key === sortField - ); + const [[, sortValue]] = Object.entries( + // @ts-expect-error @elastic/elasticsearch _source is optional + lastEsElement._source + ).filter(([key]) => key === sortField); if (typeof sortValue === 'string') { + // @ts-expect-error @elastic/elasticsearch _source is optional return [sortValue, lastEsElement._source.tie_breaker_id]; } else { + // @ts-expect-error @elastic/elasticsearch _source is optional return [lastEsElement._source.tie_breaker_id]; } } diff --git a/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts b/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts index 3fd886f8f6919..97cfe3dd8e634 100644 --- a/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts +++ b/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts @@ -4,25 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import { SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; -export interface SortWithTieBreakerReturn { - tie_breaker_id: 'asc'; - [key: string]: string; -} - export const getSortWithTieBreaker = ({ sortField, sortOrder, }: { sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; -}): SortWithTieBreakerReturn[] | undefined => { - const ascOrDesc = sortOrder ?? 'asc'; +}): estypes.SortCombinations[] => { + const ascOrDesc = sortOrder ?? ('asc' as const); if (sortField != null) { - return [{ [sortField]: ascOrDesc, tie_breaker_id: 'asc' }]; + return [{ [sortField]: ascOrDesc, tie_breaker_id: 'asc' as const }]; } else { - return [{ tie_breaker_id: 'asc' }]; + return [{ tie_breaker_id: 'asc' as const }]; } }; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts index 3dd0f083797f1..4f0f8fe49a9d0 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; import { transformElasticHitsToListItem } from './transform_elastic_to_list_item'; export interface TransformElasticMSearchToListItemOptions { - response: SearchResponse; + response: estypes.SearchResponse; type: Type; value: unknown[]; } diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts index fa77336fb7724..4ed08f70219af 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts @@ -5,19 +5,20 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ListArraySchema, SearchEsListSchema } from '../../../common/schemas'; import { encodeHitVersion } from './encode_hit_version'; export interface TransformElasticToListOptions { - response: SearchResponse; + response: estypes.SearchResponse; } export const transformElasticToList = ({ response, }: TransformElasticToListOptions): ListArraySchema => { + // @ts-expect-error created_at is incompatible return response.hits.hits.map((hit) => { return { _version: encodeHitVersion(hit), diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts index 8c1949ed90cda..436987e71dd22 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { ListItemArraySchema, SearchEsListItemSchema, Type } from '../../../common/schemas'; import { ErrorWithStatusCode } from '../../error_with_status_code'; @@ -14,12 +14,12 @@ import { encodeHitVersion } from './encode_hit_version'; import { findSourceValue } from './find_source_value'; export interface TransformElasticToListItemOptions { - response: SearchResponse; + response: estypes.SearchResponse; type: Type; } export interface TransformElasticHitToListItemOptions { - hits: SearchResponse['hits']['hits']; + hits: Array>; type: Type; } @@ -35,22 +35,21 @@ export const transformElasticHitsToListItem = ({ type, }: TransformElasticHitToListItemOptions): ListItemArraySchema => { return hits.map((hit) => { + const { _id, _source } = hit; const { - _id, - _source: { - /* eslint-disable @typescript-eslint/naming-convention */ - created_at, - deserializer, - serializer, - updated_at, - updated_by, - created_by, - list_id, - tie_breaker_id, - meta, - /* eslint-enable @typescript-eslint/naming-convention */ - }, - } = hit; + /* eslint-disable @typescript-eslint/naming-convention */ + created_at, + deserializer, + serializer, + updated_at, + updated_by, + created_by, + list_id, + tie_breaker_id, + meta, + /* eslint-enable @typescript-eslint/naming-convention */ + } = _source!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + // @ts-expect-error _source is optional const value = findSourceValue(hit._source); if (value == null) { throw new ErrorWithStatusCode(`Was expected ${type} to not be null/undefined`, 400); diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 070ad6ee98f00..ecdf94a076809 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -298,3 +298,5 @@ export type RawValue = string | number | boolean | undefined | null; export type FieldFormatter = (value: RawValue) => string | number; export const INDEX_META_DATA_CREATED_BY = 'maps-drawing-data-ingest'; + +export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB diff --git a/x-pack/plugins/maps/common/elasticsearch_util/index.ts b/x-pack/plugins/maps/common/elasticsearch_util/index.ts index 0b6eaa435264c..24dd56b217401 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/index.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/index.ts @@ -8,3 +8,4 @@ export * from './es_agg_utils'; export * from './convert_to_geojson'; export * from './elasticsearch_geo_utils'; +export { isTotalHitsGreaterThan, TotalHits } from './total_hits'; diff --git a/x-pack/plugins/maps/common/elasticsearch_util/total_hits.test.ts b/x-pack/plugins/maps/common/elasticsearch_util/total_hits.test.ts new file mode 100644 index 0000000000000..211cb2d302f2c --- /dev/null +++ b/x-pack/plugins/maps/common/elasticsearch_util/total_hits.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isTotalHitsGreaterThan, TotalHits } from './total_hits'; + +describe('total.relation: eq', () => { + const totalHits = { + value: 100, + relation: 'eq' as TotalHits['relation'], + }; + + test('total.value: 100 should be more than 90', () => { + expect(isTotalHitsGreaterThan(totalHits, 90)).toBe(true); + }); + + test('total.value: 100 should not be more than 100', () => { + expect(isTotalHitsGreaterThan(totalHits, 100)).toBe(false); + }); + + test('total.value: 100 should not be more than 110', () => { + expect(isTotalHitsGreaterThan(totalHits, 110)).toBe(false); + }); +}); + +describe('total.relation: gte', () => { + const totalHits = { + value: 100, + relation: 'gte' as TotalHits['relation'], + }; + + test('total.value: 100 should be more than 90', () => { + expect(isTotalHitsGreaterThan(totalHits, 90)).toBe(true); + }); + + test('total.value: 100 should be more than 100', () => { + expect(isTotalHitsGreaterThan(totalHits, 100)).toBe(true); + }); + + test('total.value: 100 should throw error when value is more than 100', () => { + expect(() => { + isTotalHitsGreaterThan(totalHits, 110); + }).toThrow(); + }); +}); diff --git a/x-pack/plugins/maps/common/elasticsearch_util/total_hits.ts b/x-pack/plugins/maps/common/elasticsearch_util/total_hits.ts new file mode 100644 index 0000000000000..5de38d3f28851 --- /dev/null +++ b/x-pack/plugins/maps/common/elasticsearch_util/total_hits.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export interface TotalHits { + value: number; + relation: 'eq' | 'gte'; +} + +export function isTotalHitsGreaterThan(totalHits: TotalHits, value: number) { + if (totalHits.relation === 'eq') { + return totalHits.value > value; + } + + if (value > totalHits.value) { + throw new Error( + i18n.translate('xpack.maps.totalHits.lowerBoundPrecisionExceeded', { + defaultMessage: `Unable to determine if total hits is greater than value. Total hits precision is lower then value. Total hits: {totalHitsString}, value: {value}. Ensure _search.body.track_total_hits is at least as large as value.`, + values: { + totalHitsString: JSON.stringify(totalHits, null, ''), + value, + }, + }) + ); + } + + return true; +} diff --git a/x-pack/plugins/maps/common/types.ts b/x-pack/plugins/maps/common/types.ts index 806eac597ac57..6f2bd72c80896 100644 --- a/x-pack/plugins/maps/common/types.ts +++ b/x-pack/plugins/maps/common/types.ts @@ -22,3 +22,9 @@ export interface IndexSourceMappings { export interface BodySettings { [key: string]: any; } + +export interface WriteSettings { + index: string; + body: object; + [key: string]: any; +} diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index d795315acbf50..6dd454137be7d 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -22,6 +22,7 @@ import { LAYER_STYLE_TYPE, FIELD_ORIGIN, } from '../../../../common/constants'; +import { isTotalHitsGreaterThan, TotalHits } from '../../../../common/elasticsearch_util'; import { ESGeoGridSource } from '../../sources/es_geo_grid_source/es_geo_grid_source'; import { canSkipSourceUpdate } from '../../util/can_skip_fetch'; import { IESSource } from '../../sources/es_source'; @@ -323,13 +324,18 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { syncContext.startLoading(dataRequestId, requestToken, searchFilters); const abortController = new AbortController(); syncContext.registerCancelCallback(requestToken, () => abortController.abort()); + const maxResultWindow = await this._documentSource.getMaxResultWindow(); const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); + searchSource.setField('trackTotalHits', maxResultWindow + 1); const resp = await searchSource.fetch({ abortSignal: abortController.signal, sessionId: syncContext.dataFilters.searchSessionId, + legacyHitsTotal: false, }); - const maxResultWindow = await this._documentSource.getMaxResultWindow(); - isSyncClustered = resp.hits.total > maxResultWindow; + isSyncClustered = isTotalHitsGreaterThan( + (resp.hits.total as unknown) as TotalHits, + maxResultWindow + ); const countData = { isSyncClustered } as CountData; syncContext.stopLoading(dataRequestId, requestToken, countData, searchFilters); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 4715398dab97b..7910e931e60e6 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -368,6 +368,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle ): Promise { const indexPattern: IndexPattern = await this.getIndexPattern(); const searchSource: ISearchSource = await this.makeSearchSource(searchFilters, 0); + searchSource.setField('trackTotalHits', false); let bucketsPerGrid = 1; this.getMetricFields().forEach((metricField) => { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx index c652935d7188a..9a1f23e055af1 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -190,6 +190,7 @@ export class ESGeoLineSource extends AbstractESAggSource { // Fetch entities // const entitySearchSource = await this.makeSearchSource(searchFilters, 0); + entitySearchSource.setField('trackTotalHits', false); const splitField = getField(indexPattern, this._descriptor.splitField); const cardinalityAgg = { precision_threshold: 1 }; const termsAgg = { size: MAX_TRACKS }; @@ -250,6 +251,7 @@ export class ESGeoLineSource extends AbstractESAggSource { const tracksSearchFilters = { ...searchFilters }; delete tracksSearchFilters.buffer; const tracksSearchSource = await this.makeSearchSource(tracksSearchFilters, 0); + tracksSearchSource.setField('trackTotalHits', false); tracksSearchSource.setField('aggs', { tracks: { filters: { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index e3ee9599d86a9..781cc7f8c36b0 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -109,6 +109,7 @@ export class ESPewPewSource extends AbstractESAggSource { async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); + searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { destSplit: { terms: { @@ -168,6 +169,7 @@ export class ESPewPewSource extends AbstractESAggSource { async getBoundsForFilters(boundsFilters, registerCancelCallback) { const searchSource = await this.makeSearchSource(boundsFilters, 0); + searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { destFitToBounds: { geo_bounds: { @@ -185,7 +187,10 @@ export class ESPewPewSource extends AbstractESAggSource { try { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const esResp = await searchSource.fetch({ abortSignal: abortController.signal }); + const esResp = await searchSource.fetch({ + abortSignal: abortController.signal, + legacyHitsTotal: false, + }); if (esResp.aggregations.destFitToBounds.bounds) { corners.push([ esResp.aggregations.destFitToBounds.bounds.top_left.lon, diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 3b6a7202691b6..eae00710c4c25 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -18,6 +18,7 @@ import { addFieldToDSL, getField, hitsToGeoJson, + isTotalHitsGreaterThan, PreIndexedShape, } from '../../../../common/elasticsearch_util'; // @ts-expect-error @@ -313,6 +314,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye }; const searchSource = await this.makeSearchSource(searchFilters, 0); + searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { totalEntities: { cardinality: addFieldToDSL(cardinalityAgg, topHitsSplitField), @@ -343,11 +345,10 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye const areEntitiesTrimmed = entityBuckets.length >= DEFAULT_MAX_BUCKETS_LIMIT; let areTopHitsTrimmed = false; entityBuckets.forEach((entityBucket: any) => { - const total = _.get(entityBucket, 'entityHits.hits.total', 0); const hits = _.get(entityBucket, 'entityHits.hits.hits', []); // Reverse hits list so top documents by sort are drawn on top allHits.push(...hits.reverse()); - if (total > hits.length) { + if (isTotalHitsGreaterThan(entityBucket.entityHits.hits.total, hits.length)) { areTopHitsTrimmed = true; } }); @@ -385,6 +386,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye maxResultWindow, initialSearchContext ); + searchSource.setField('trackTotalHits', maxResultWindow + 1); searchSource.setField('fieldsFromSource', searchFilters.fieldNames); // Setting "fields" filters out unused scripted fields if (sourceOnlyFields.length === 0) { searchSource.setField('source', false); // do not need anything from _source @@ -408,7 +410,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye hits: resp.hits.hits.reverse(), // Reverse hits so top documents by sort are drawn on top meta: { resultsCount: resp.hits.hits.length, - areResultsTrimmed: resp.hits.total > resp.hits.hits.length, + areResultsTrimmed: isTotalHitsGreaterThan(resp.hits.total, resp.hits.hits.length), }, }; } @@ -508,6 +510,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye const initialSearchContext = { docvalue_fields: docValueFields }; // Request fields in docvalue_fields insted of _source const searchService = getSearchService(); const searchSource = await searchService.searchSource.create(initialSearchContext as object); + searchSource.setField('trackTotalHits', false); searchSource.setField('index', indexPattern); searchSource.setField('size', 1); @@ -520,7 +523,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource.setField('query', query); searchSource.setField('fieldsFromSource', this._getTooltipPropertyNames()); - const resp = await searchSource.fetch(); + const resp = await searchSource.fetch({ legacyHitsTotal: false }); const hit = _.get(resp, 'hits.hits[0]'); if (!hit) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 0936cdc50b4c0..222c49abfa16a 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -195,6 +195,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource resp = await searchSource.fetch({ abortSignal: abortController.signal, sessionId: searchSessionId, + legacyHitsTotal: false, }); if (inspectorRequest) { const responseStats = search.getResponseInspectorStats(resp, searchSource); @@ -247,6 +248,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } } const searchService = getSearchService(); + const searchSource = await searchService.searchSource.create(initialSearchContext); searchSource.setField('index', indexPattern); @@ -272,6 +274,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback: (callback: () => void) => void ): Promise { const searchSource = await this.makeSearchSource(boundsFilters, 0); + searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { fitToBounds: { geo_bounds: { @@ -284,12 +287,33 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource try { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const esResp = await searchSource.fetch({ abortSignal: abortController.signal }); - if (!esResp.aggregations.fitToBounds.bounds) { + const esResp = await searchSource.fetch({ + abortSignal: abortController.signal, + legacyHitsTotal: false, + }); + + if (!esResp.aggregations) { + return null; + } + + const fitToBounds = esResp.aggregations.fitToBounds as { + bounds?: { + top_left: { + lat: number; + lon: number; + }; + bottom_right: { + lat: number; + lon: number; + }; + }; + }; + + if (!fitToBounds.bounds) { // aggregations.fitToBounds is empty object when there are no matching documents return null; } - esBounds = esResp.aggregations.fitToBounds.bounds; + esBounds = fitToBounds.bounds; } catch (error) { if (error.name === 'AbortError') { throw new DataRequestAbortError(); diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 5c41971fb629c..caae4385aeec6 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -127,6 +127,7 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource const indexPattern = await this.getIndexPattern(); const searchSource: ISearchSource = await this.makeSearchSource(searchFilters, 0); + searchSource.setField('trackTotalHits', false); const termsField = getField(indexPattern, this._termField.getName()); const termsAgg = { size: this._descriptor.size !== undefined ? this._descriptor.size : DEFAULT_MAX_BUCKETS_LIMIT, diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx index f68875dc81394..a1bea4a8e93dc 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx @@ -12,20 +12,10 @@ import MapboxDraw from '@mapbox/mapbox-gl-draw'; // @ts-expect-error import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import { Map as MbMap } from 'mapbox-gl'; -import { i18n } from '@kbn/i18n'; -import { Filter } from 'src/plugins/data/public'; -import { Feature, Polygon } from 'geojson'; -import { DRAW_TYPE, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../../../common/constants'; -import { DrawState } from '../../../../common/descriptor_types'; -import { DrawCircle, DrawCircleProperties } from './draw_circle'; -import { - createDistanceFilterWithMeta, - createSpatialFilterWithGeometry, - getBoundingBoxGeometry, - roundCoordinates, -} from '../../../../common/elasticsearch_util'; +import { Feature } from 'geojson'; +import { DRAW_TYPE } from '../../../../common/constants'; +import { DrawCircle } from './draw_circle'; import { DrawTooltip } from './draw_tooltip'; -import { getToasts } from '../../../kibana_services'; const DRAW_RECTANGLE = 'draw_rectangle'; const DRAW_CIRCLE = 'draw_circle'; @@ -35,10 +25,8 @@ mbDrawModes[DRAW_RECTANGLE] = DrawRectangle; mbDrawModes[DRAW_CIRCLE] = DrawCircle; export interface Props { - addFilters: (filters: Filter[], actionId: string) => Promise; - disableDrawState: () => void; - drawState?: DrawState; - isDrawingFilter: boolean; + drawType?: DRAW_TYPE; + onDraw: (event: { features: Feature[] }) => void; mbMap: MbMap; } @@ -70,100 +58,26 @@ export class DrawControl extends Component { return; } - if (this.props.isDrawingFilter) { + if (this.props.drawType) { this._updateDrawControl(); } else { this._removeDrawControl(); } }, 0); - _onDraw = async (e: { features: Feature[] }) => { - if ( - !e.features.length || - !this.props.drawState || - !this.props.drawState.geoFieldName || - !this.props.drawState.indexPatternId - ) { - return; - } - - let filter: Filter | undefined; - if (this.props.drawState.drawType === DRAW_TYPE.DISTANCE) { - const circle = e.features[0] as Feature & { properties: DrawCircleProperties }; - const distanceKm = _.round( - circle.properties.radiusKm, - circle.properties.radiusKm > 10 ? 0 : 2 - ); - // Only include as much precision as needed for distance - let precision = 2; - if (distanceKm <= 1) { - precision = 5; - } else if (distanceKm <= 10) { - precision = 4; - } else if (distanceKm <= 100) { - precision = 3; - } - filter = createDistanceFilterWithMeta({ - alias: this.props.drawState.filterLabel ? this.props.drawState.filterLabel : '', - distanceKm, - geoFieldName: this.props.drawState.geoFieldName, - indexPatternId: this.props.drawState.indexPatternId, - point: [ - _.round(circle.properties.center[0], precision), - _.round(circle.properties.center[1], precision), - ], - }); - } else { - const geometry = e.features[0].geometry as Polygon; - // MapboxDraw returns coordinates with 12 decimals. Round to a more reasonable number - roundCoordinates(geometry.coordinates); - - filter = createSpatialFilterWithGeometry({ - geometry: - this.props.drawState.drawType === DRAW_TYPE.BOUNDS - ? getBoundingBoxGeometry(geometry) - : geometry, - indexPatternId: this.props.drawState.indexPatternId, - geoFieldName: this.props.drawState.geoFieldName, - geoFieldType: this.props.drawState.geoFieldType - ? this.props.drawState.geoFieldType - : ES_GEO_FIELD_TYPE.GEO_POINT, - geometryLabel: this.props.drawState.geometryLabel ? this.props.drawState.geometryLabel : '', - relation: this.props.drawState.relation - ? this.props.drawState.relation - : ES_SPATIAL_RELATIONS.INTERSECTS, - }); - } - - try { - await this.props.addFilters([filter!], this.props.drawState.actionId); - } catch (error) { - getToasts().addWarning( - i18n.translate('xpack.maps.drawControl.unableToCreatFilter', { - defaultMessage: `Unable to create filter, error: '{errorMsg}'.`, - values: { - errorMsg: error.message, - }, - }) - ); - } finally { - this.props.disableDrawState(); - } - }; - _removeDrawControl() { if (!this._mbDrawControlAdded) { return; } this.props.mbMap.getCanvas().style.cursor = ''; - this.props.mbMap.off('draw.create', this._onDraw); + this.props.mbMap.off('draw.create', this.props.onDraw); this.props.mbMap.removeControl(this._mbDrawControl); this._mbDrawControlAdded = false; } _updateDrawControl() { - if (!this.props.drawState) { + if (!this.props.drawType) { return; } @@ -171,27 +85,27 @@ export class DrawControl extends Component { this.props.mbMap.addControl(this._mbDrawControl); this._mbDrawControlAdded = true; this.props.mbMap.getCanvas().style.cursor = 'crosshair'; - this.props.mbMap.on('draw.create', this._onDraw); + this.props.mbMap.on('draw.create', this.props.onDraw); } const drawMode = this._mbDrawControl.getMode(); - if (drawMode !== DRAW_RECTANGLE && this.props.drawState.drawType === DRAW_TYPE.BOUNDS) { + if (drawMode !== DRAW_RECTANGLE && this.props.drawType === DRAW_TYPE.BOUNDS) { this._mbDrawControl.changeMode(DRAW_RECTANGLE); - } else if (drawMode !== DRAW_CIRCLE && this.props.drawState.drawType === DRAW_TYPE.DISTANCE) { + } else if (drawMode !== DRAW_CIRCLE && this.props.drawType === DRAW_TYPE.DISTANCE) { this._mbDrawControl.changeMode(DRAW_CIRCLE); } else if ( drawMode !== this._mbDrawControl.modes.DRAW_POLYGON && - this.props.drawState.drawType === DRAW_TYPE.POLYGON + this.props.drawType === DRAW_TYPE.POLYGON ) { this._mbDrawControl.changeMode(this._mbDrawControl.modes.DRAW_POLYGON); } } render() { - if (!this.props.isDrawingFilter || !this.props.drawState) { + if (!this.props.drawType) { return null; } - return ; + return ; } } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx new file mode 100644 index 0000000000000..c0cbd3566ca8f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/draw_filter_control.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import React, { Component } from 'react'; +import { Map as MbMap } from 'mapbox-gl'; +import { i18n } from '@kbn/i18n'; +import { Filter } from 'src/plugins/data/public'; +import { Feature, Polygon } from 'geojson'; +import { + DRAW_TYPE, + ES_GEO_FIELD_TYPE, + ES_SPATIAL_RELATIONS, +} from '../../../../../common/constants'; +import { DrawState } from '../../../../../common/descriptor_types'; +import { + createDistanceFilterWithMeta, + createSpatialFilterWithGeometry, + getBoundingBoxGeometry, + roundCoordinates, +} from '../../../../../common/elasticsearch_util'; +import { getToasts } from '../../../../kibana_services'; +import { DrawControl } from '../draw_control'; +import { DrawCircleProperties } from '../draw_circle'; + +export interface Props { + addFilters: (filters: Filter[], actionId: string) => Promise; + disableDrawState: () => void; + drawState?: DrawState; + isDrawingFilter: boolean; + mbMap: MbMap; +} + +export class DrawFilterControl extends Component { + _onDraw = async (e: { features: Feature[] }) => { + if ( + !e.features.length || + !this.props.drawState || + !this.props.drawState.geoFieldName || + !this.props.drawState.indexPatternId + ) { + return; + } + + let filter: Filter | undefined; + if (this.props.drawState.drawType === DRAW_TYPE.DISTANCE) { + const circle = e.features[0] as Feature & { properties: DrawCircleProperties }; + const distanceKm = _.round( + circle.properties.radiusKm, + circle.properties.radiusKm > 10 ? 0 : 2 + ); + // Only include as much precision as needed for distance + let precision = 2; + if (distanceKm <= 1) { + precision = 5; + } else if (distanceKm <= 10) { + precision = 4; + } else if (distanceKm <= 100) { + precision = 3; + } + filter = createDistanceFilterWithMeta({ + alias: this.props.drawState.filterLabel ? this.props.drawState.filterLabel : '', + distanceKm, + geoFieldName: this.props.drawState.geoFieldName, + indexPatternId: this.props.drawState.indexPatternId, + point: [ + _.round(circle.properties.center[0], precision), + _.round(circle.properties.center[1], precision), + ], + }); + } else { + const geometry = e.features[0].geometry as Polygon; + // MapboxDraw returns coordinates with 12 decimals. Round to a more reasonable number + roundCoordinates(geometry.coordinates); + + filter = createSpatialFilterWithGeometry({ + geometry: + this.props.drawState.drawType === DRAW_TYPE.BOUNDS + ? getBoundingBoxGeometry(geometry) + : geometry, + indexPatternId: this.props.drawState.indexPatternId, + geoFieldName: this.props.drawState.geoFieldName, + geoFieldType: this.props.drawState.geoFieldType + ? this.props.drawState.geoFieldType + : ES_GEO_FIELD_TYPE.GEO_POINT, + geometryLabel: this.props.drawState.geometryLabel ? this.props.drawState.geometryLabel : '', + relation: this.props.drawState.relation + ? this.props.drawState.relation + : ES_SPATIAL_RELATIONS.INTERSECTS, + }); + } + + try { + await this.props.addFilters([filter!], this.props.drawState.actionId); + } catch (error) { + getToasts().addWarning( + i18n.translate('xpack.maps.drawFilterControl.unableToCreatFilter', { + defaultMessage: `Unable to create filter, error: '{errorMsg}'.`, + values: { + errorMsg: error.message, + }, + }) + ); + } finally { + this.props.disableDrawState(); + } + }; + + render() { + return ( + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts new file mode 100644 index 0000000000000..17f4d919fb7e0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { connect } from 'react-redux'; +import { DrawFilterControl } from './draw_filter_control'; +import { updateDrawState } from '../../../../actions'; +import { getDrawState, isDrawingFilter } from '../../../../selectors/map_selectors'; +import { MapStoreState } from '../../../../reducers/store'; + +function mapStateToProps(state: MapStoreState) { + return { + isDrawingFilter: isDrawingFilter(state), + drawState: getDrawState(state), + }; +} + +function mapDispatchToProps(dispatch: ThunkDispatch) { + return { + disableDrawState() { + dispatch(updateDrawState(null)); + }, + }; +} + +const connected = connect(mapStateToProps, mapDispatchToProps)(DrawFilterControl); +export { connected as DrawFilterControl }; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.tsx index 099f409c91c21..df650d5dfe410 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.tsx @@ -11,13 +11,12 @@ import { EuiPopover, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Map as MbMap } from 'mapbox-gl'; import { DRAW_TYPE } from '../../../../common/constants'; -import { DrawState } from '../../../../common/descriptor_types'; const noop = () => {}; interface Props { mbMap: MbMap; - drawState: DrawState; + drawType: DRAW_TYPE; } interface State { @@ -58,16 +57,16 @@ export class DrawTooltip extends Component { } let instructions; - if (this.props.drawState.drawType === DRAW_TYPE.BOUNDS) { + if (this.props.drawType === DRAW_TYPE.BOUNDS) { instructions = i18n.translate('xpack.maps.drawTooltip.boundsInstructions', { defaultMessage: 'Click to start rectangle. Move mouse to adjust rectangle size. Click again to finish.', }); - } else if (this.props.drawState.drawType === DRAW_TYPE.DISTANCE) { + } else if (this.props.drawType === DRAW_TYPE.DISTANCE) { instructions = i18n.translate('xpack.maps.drawTooltip.distanceInstructions', { defaultMessage: 'Click to set point. Move mouse to adjust distance. Click to finish.', }); - } else if (this.props.drawState.drawType === DRAW_TYPE.POLYGON) { + } else if (this.props.drawType === DRAW_TYPE.POLYGON) { instructions = i18n.translate('xpack.maps.drawTooltip.polygonInstructions', { defaultMessage: 'Click to start shape. Click to add vertex. Double click to finish.', }); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts index cc2f560c63d24..63f91a03a5d01 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.ts @@ -5,28 +5,4 @@ * 2.0. */ -import { AnyAction } from 'redux'; -import { ThunkDispatch } from 'redux-thunk'; -import { connect } from 'react-redux'; -import { DrawControl } from './draw_control'; -import { updateDrawState } from '../../../actions'; -import { getDrawState, isDrawingFilter } from '../../../selectors/map_selectors'; -import { MapStoreState } from '../../../reducers/store'; - -function mapStateToProps(state: MapStoreState) { - return { - isDrawingFilter: isDrawingFilter(state), - drawState: getDrawState(state), - }; -} - -function mapDispatchToProps(dispatch: ThunkDispatch) { - return { - disableDrawState() { - dispatch(updateDrawState(null)); - }, - }; -} - -const connected = connect(mapStateToProps, mapDispatchToProps)(DrawControl); -export { connected as DrawControl }; +export { DrawFilterControl } from './draw_filter_control'; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index fae89a0484f11..5e4c3c9b1981f 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -17,7 +17,7 @@ import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { Adapters } from 'src/plugins/inspector/public'; import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; -import { DrawControl } from './draw_control'; +import { DrawFilterControl } from './draw_control'; import { ScaleControl } from './scale_control'; // @ts-expect-error import { TooltipControl } from './tooltip_control'; @@ -418,7 +418,7 @@ export class MBMap extends Component { let scaleControl; if (this.state.mbMap) { drawControl = this.props.addFilters ? ( - + ) : null; tooltipControl = !this.props.settings.disableTooltipControl ? ( { type = MAP_SAVED_OBJECT_TYPE; + private _isActive: boolean; private _savedMap: SavedMap; private _renderTooltipContent?: RenderToolTipContent; private _subscription: Subscription; @@ -118,6 +119,7 @@ export class MapEmbeddable parent ); + this._isActive = true; this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput }); this._initializeSaveMap(); this._subscription = this.getUpdated$().subscribe(() => this.onUpdate()); @@ -404,6 +406,7 @@ export class MapEmbeddable destroy() { super.destroy(); + this._isActive = false; if (this._unsubscribeFromStore) { this._unsubscribeFromStore(); } @@ -424,6 +427,9 @@ export class MapEmbeddable } _handleStoreChanges() { + if (!this._isActive) { + return; + } const center = getMapCenter(this._savedMap.getStore().getState()); const zoom = getMapZoom(this._savedMap.getStore().getState()); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index 58268b6ea9d82..a7374650d0451 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -5,7 +5,8 @@ * 2.0. */ -jest.mock('../classes/layers/vector_layer', () => {}); +import { LAYER_STYLE_TYPE, LAYER_TYPE, SOURCE_TYPES } from '../../common'; + jest.mock('../classes/layers/tiled_vector_layer/tiled_vector_layer', () => {}); jest.mock('../classes/layers/blended_vector_layer/blended_vector_layer', () => {}); jest.mock('../classes/layers/heatmap_layer', () => {}); @@ -23,13 +24,23 @@ jest.mock('../kibana_services', () => ({ getMapsCapabilities() { return { save: true }; }, + getIsDarkMode() { + return false; + }, })); import { DEFAULT_MAP_STORE_STATE } from '../reducers/store'; -import { areLayersLoaded, getDataFilters, getTimeFilters } from './map_selectors'; -import { LayerDescriptor } from '../../common/descriptor_types'; +import { + areLayersLoaded, + getDataFilters, + getTimeFilters, + getQueryableUniqueIndexPatternIds, +} from './map_selectors'; + +import { LayerDescriptor, VectorLayerDescriptor } from '../../common/descriptor_types'; import { ILayer } from '../classes/layers/layer'; import { Filter } from '../../../../../src/plugins/data/public'; +import { ESSearchSource } from '../classes/sources/es_search_source'; describe('getDataFilters', () => { const mapExtent = { @@ -193,3 +204,76 @@ describe('areLayersLoaded', () => { expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); }); }); + +describe('getQueryableUniqueIndexPatternIds', () => { + function createLayerMock({ + isVisible = true, + indexPatterns = [], + }: { + isVisible?: boolean; + indexPatterns?: string[]; + }) { + return ({ + isVisible: () => { + return isVisible; + }, + getQueryableIndexPatternIds: () => { + return indexPatterns; + }, + } as unknown) as ILayer; + } + + function createWaitLayerDescriptorMock({ + indexPatternId, + visible = true, + }: { + visible?: boolean; + indexPatternId: string; + }) { + return { + type: LAYER_TYPE.VECTOR, + style: { + type: LAYER_STYLE_TYPE.VECTOR, + }, + visible, + sourceDescriptor: ESSearchSource.createDescriptor({ + type: SOURCE_TYPES.ES_SEARCH, + indexPatternId, + geoField: 'field', + }), + }; + } + + test('should only include visible', () => { + const layerList: ILayer[] = [ + createLayerMock({}), + createLayerMock({ indexPatterns: ['foo'] }), + createLayerMock({ indexPatterns: ['bar'] }), + createLayerMock({ indexPatterns: ['foobar'], isVisible: false }), + createLayerMock({ indexPatterns: ['bar'] }), + ]; + const waitingForMapReadyLayerList: VectorLayerDescriptor[] = ([] as unknown) as VectorLayerDescriptor[]; + expect( + getQueryableUniqueIndexPatternIds.resultFunc(layerList, waitingForMapReadyLayerList) + ).toEqual(['foo', 'bar']); + }); + + test('should only include visible and waitlist should take precedence', () => { + const layerList: ILayer[] = [ + createLayerMock({}), + createLayerMock({ indexPatterns: ['foo'] }), + createLayerMock({ indexPatterns: ['bar'] }), + createLayerMock({ indexPatterns: ['foobar'], isVisible: false }), + createLayerMock({ indexPatterns: ['bar'] }), + ]; + const waitingForMapReadyLayerList: VectorLayerDescriptor[] = ([ + createWaitLayerDescriptorMock({ indexPatternId: 'foo' }), + createWaitLayerDescriptorMock({ indexPatternId: 'barfoo', visible: false }), + createWaitLayerDescriptorMock({ indexPatternId: 'fbr' }), + createWaitLayerDescriptorMock({ indexPatternId: 'foo' }), + ] as unknown) as VectorLayerDescriptor[]; + expect( + getQueryableUniqueIndexPatternIds.resultFunc(layerList, waitingForMapReadyLayerList) + ).toEqual(['foo', 'fbr']); + }); +}); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 2f87aa7d11394..a818cdd2d00f9 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -377,16 +377,6 @@ export const getSelectedLayerJoinDescriptors = createSelector(getSelectedLayer, }); }); -// Get list of unique index patterns used by all layers -export const getUniqueIndexPatternIds = createSelector(getLayerList, (layerList) => { - const indexPatternIds: string[] = []; - layerList.forEach((layer) => { - indexPatternIds.push(...layer.getIndexPatternIds()); - }); - return _.uniq(indexPatternIds).sort(); -}); - -// Get list of unique index patterns, excluding index patterns from layers that disable applyGlobalQuery export const getQueryableUniqueIndexPatternIds = createSelector( getLayerList, getWaitingForMapReadyLayerListRaw, @@ -396,11 +386,15 @@ export const getQueryableUniqueIndexPatternIds = createSelector( if (waitingForMapReadyLayerList.length) { waitingForMapReadyLayerList.forEach((layerDescriptor) => { const layer = createLayerInstance(layerDescriptor); - indexPatternIds.push(...layer.getQueryableIndexPatternIds()); + if (layer.isVisible()) { + indexPatternIds.push(...layer.getQueryableIndexPatternIds()); + } }); } else { layerList.forEach((layer) => { - indexPatternIds.push(...layer.getQueryableIndexPatternIds()); + if (layer.isVisible()) { + indexPatternIds.push(...layer.getQueryableIndexPatternIds()); + } }); } return _.uniq(indexPatternIds); diff --git a/x-pack/plugins/maps/server/create_doc_source.ts b/x-pack/plugins/maps/server/data_indexing/create_doc_source.ts similarity index 84% rename from x-pack/plugins/maps/server/create_doc_source.ts rename to x-pack/plugins/maps/server/data_indexing/create_doc_source.ts index 641a2acf42384..2b8984aa1534a 100644 --- a/x-pack/plugins/maps/server/create_doc_source.ts +++ b/x-pack/plugins/maps/server/data_indexing/create_doc_source.ts @@ -11,8 +11,8 @@ import { CreateDocSourceResp, IndexSourceMappings, BodySettings, -} from '../common'; -import { IndexPatternsService } from '../../../../src/plugins/data/common'; +} from '../../common'; +import { IndexPatternsCommonService } from '../../../../../src/plugins/data/server'; const DEFAULT_SETTINGS = { number_of_shards: 1 }; const DEFAULT_MAPPINGS = { @@ -25,16 +25,11 @@ export async function createDocSource( index: string, mappings: IndexSourceMappings, { asCurrentUser }: IScopedClusterClient, - indexPatternsService: IndexPatternsService + indexPatternsService: IndexPatternsCommonService ): Promise { try { await createIndex(index, mappings, asCurrentUser); - await indexPatternsService.createAndSave( - { - title: index, - }, - true - ); + await indexPatternsService.createAndSave({ title: index }, true); return { success: true, diff --git a/x-pack/plugins/maps/server/data_indexing/index_data.ts b/x-pack/plugins/maps/server/data_indexing/index_data.ts new file mode 100644 index 0000000000000..b87cd53a3dfd2 --- /dev/null +++ b/x-pack/plugins/maps/server/data_indexing/index_data.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 { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; +import { WriteSettings } from '../../common'; + +export async function writeDataToIndex( + index: string, + data: object, + asCurrentUser: ElasticsearchClient +) { + try { + const { body: indexExists } = await asCurrentUser.indices.exists({ index }); + if (!indexExists) { + throw new Error( + i18n.translate('xpack.maps.indexData.indexExists', { + defaultMessage: `Index: '{index}' not found. A valid index must be provided`, + values: { + index, + }, + }) + ); + } + const settings: WriteSettings = { index, body: data }; + const { body: resp } = await asCurrentUser.index(settings); + if (resp.result === 'Error') { + throw resp; + } else { + return { + success: true, + data, + }; + } + } catch (error) { + return { + success: false, + error, + }; + } +} diff --git a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts new file mode 100644 index 0000000000000..e6e6471ff9af6 --- /dev/null +++ b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { Logger } from 'src/core/server'; +import { IRouter } from 'src/core/server'; +import type { DataRequestHandlerContext } from 'src/plugins/data/server'; +import { + INDEX_SOURCE_API_PATH, + GIS_API_PATH, + MAX_DRAWING_SIZE_BYTES, +} from '../../common/constants'; +import { createDocSource } from './create_doc_source'; +import { writeDataToIndex } from './index_data'; +import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server'; + +export function initIndexingRoutes({ + router, + logger, + dataPlugin, +}: { + router: IRouter; + logger: Logger; + dataPlugin: DataPluginStart; +}) { + router.post( + { + path: `/${INDEX_SOURCE_API_PATH}`, + validate: { + body: schema.object({ + index: schema.string(), + mappings: schema.any(), + }), + }, + options: { + body: { + accepts: ['application/json'], + }, + }, + }, + async (context, request, response) => { + const { index, mappings } = request.body; + const indexPatternsService = await dataPlugin.indexPatterns.indexPatternsServiceFactory( + context.core.savedObjects.client, + context.core.elasticsearch.client.asCurrentUser + ); + const result = await createDocSource( + index, + mappings, + context.core.elasticsearch.client, + indexPatternsService + ); + if (result.success) { + return response.ok({ body: result }); + } else { + if (result.error) { + logger.error(result.error); + } + return response.custom({ + body: result?.error?.message, + statusCode: 500, + }); + } + } + ); + + router.post( + { + path: `/${GIS_API_PATH}/feature`, + validate: { + body: schema.object({ + index: schema.string(), + data: schema.any(), + }), + }, + options: { + body: { + accepts: ['application/json'], + maxBytes: MAX_DRAWING_SIZE_BYTES, + }, + }, + }, + async (context, request, response) => { + const result = await writeDataToIndex( + request.body.index, + request.body.data, + context.core.elasticsearch.client.asCurrentUser + ); + if (result.success) { + return response.ok({ body: result }); + } else { + logger.error(result.error); + return response.custom({ + body: result.error.message, + statusCode: 500, + }); + } + } + ); +} diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 3274261cdba56..95b8e043e0ce4 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -23,7 +23,12 @@ import { SUPER_FINE_ZOOM_DELTA, } from '../../common/constants'; -import { convertRegularRespToGeoJson, hitsToGeoJson } from '../../common/elasticsearch_util'; +import { + convertRegularRespToGeoJson, + hitsToGeoJson, + isTotalHitsGreaterThan, + TotalHits, +} from '../../common/elasticsearch_util'; import { flattenHit } from './util'; import { ESBounds, tileToESBbox } from '../../common/geo_tile_utils'; import { getCentroidFeatures } from '../../common/get_centroid_features'; @@ -67,6 +72,7 @@ export async function getGridTile({ MAX_ZOOM ); requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.bounds = tileBounds; + requestBody.track_total_hits = false; const response = await context .search!.search( @@ -78,6 +84,7 @@ export async function getGridTile({ }, { sessionId: searchSessionId, + legacyHitsTotal: false, abortSignal, } ) @@ -130,6 +137,7 @@ export async function getTile({ const searchOptions = { sessionId: searchSessionId, + legacyHitsTotal: false, abortSignal, }; @@ -141,6 +149,7 @@ export async function getTile({ body: { size: 0, query: requestBody.query, + track_total_hits: requestBody.size + 1, }, }, }, @@ -148,7 +157,12 @@ export async function getTile({ ) .toPromise(); - if (countResponse.rawResponse.hits.total > requestBody.size) { + if ( + isTotalHitsGreaterThan( + (countResponse.rawResponse.hits.total as unknown) as TotalHits, + requestBody.size + ) + ) { // Generate "too many features"-bounds const bboxResponse = await context .search!.search( @@ -165,6 +179,7 @@ export async function getTile({ }, }, }, + track_total_hits: false, }, }, }, @@ -179,6 +194,7 @@ export async function getTile({ [KBN_TOO_MANY_FEATURES_PROPERTY]: true, }, geometry: esBboxToGeoJsonPolygon( + // @ts-expect-error @elastic/elasticsearch no way to declare aggregations for search response bboxResponse.rawResponse.aggregations.data_bounds.bounds, tileToESBbox(x, y, z) ), @@ -190,7 +206,10 @@ export async function getTile({ { params: { index, - body: requestBody, + body: { + ...requestBody, + track_total_hits: false, + }, }, }, searchOptions @@ -199,6 +218,7 @@ export async function getTile({ // Todo: pass in epochMillies-fields const featureCollection = hitsToGeoJson( + // @ts-expect-error hitsToGeoJson should be refactored to accept estypes.Hit documentsResponse.rawResponse.hits.hits, (hit: Record) => { return flattenHit(geometryFieldName, hit); diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index d4c0652fa535c..39ce9979870c5 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -24,8 +24,7 @@ import { INDEX_SETTINGS_API_PATH, FONTS_API_PATH, API_ROOT_PATH, - INDEX_SOURCE_API_PATH, -} from '../common/constants'; +} from '../common'; import { EMSClient } from '@elastic/ems-client'; import fetch from 'node-fetch'; import { i18n } from '@kbn/i18n'; @@ -34,7 +33,7 @@ import { schema } from '@kbn/config-schema'; import fs from 'fs'; import path from 'path'; import { initMVTRoutes } from './mvt/mvt_routes'; -import { createDocSource } from './create_doc_source'; +import { initIndexingRoutes } from './data_indexing/indexing_routes'; const EMPTY_EMS_CLIENT = { async getFileLayers() { @@ -575,13 +574,10 @@ export async function initRoutes( } try { - const resp = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'indices.getSettings', - { - index: query.indexPatternTitle, - } - ); - const indexPatternSettings = getIndexPatternSettings(resp); + const resp = await context.core.elasticsearch.client.asCurrentUser.indices.getSettings({ + index: query.indexPatternTitle, + }); + const indexPatternSettings = getIndexPatternSettings(resp.body); return response.ok({ body: indexPatternSettings, }); @@ -597,47 +593,6 @@ export async function initRoutes( } ); - if (drawingFeatureEnabled) { - router.post( - { - path: `/${INDEX_SOURCE_API_PATH}`, - validate: { - body: schema.object({ - index: schema.string(), - mappings: schema.any(), - }), - }, - options: { - body: { - accepts: ['application/json'], - }, - }, - }, - async (context, request, response) => { - const { index, mappings } = request.body; - const indexPatternsService = await dataPlugin.indexPatterns.indexPatternsServiceFactory( - context.core.savedObjects.client, - context.core.elasticsearch.client.asCurrentUser - ); - const result = await createDocSource( - index, - mappings, - context.core.elasticsearch.client, - indexPatternsService - ); - if (result.success) { - return response.ok({ body: result }); - } else { - logger.error(result.error); - return response.custom({ - body: result.error.message, - statusCode: 500, - }); - } - } - ); - } - function checkEMSProxyEnabled() { const proxyEMSInMaps = emsSettings.isProxyElasticMapsServiceInMaps(); if (!proxyEMSInMaps) { @@ -669,4 +624,7 @@ export async function initRoutes( } initMVTRoutes({ router, logger }); + if (drawingFeatureEnabled) { + initIndexingRoutes({ router, logger, dataPlugin }); + } } diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts index ed9c9e7589749..77d453b68edc5 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts @@ -5,33 +5,41 @@ * 2.0. */ -import { IndexPatternTitle } from '../kibana'; -import { RuntimeMappings } from '../fields'; -import { JobId } from './job'; +import { estypes } from '@elastic/elasticsearch'; +// import { IndexPatternTitle } from '../kibana'; +// import { RuntimeMappings } from '../fields'; +// import { JobId } from './job'; export type DatafeedId = string; -export interface Datafeed { - datafeed_id: DatafeedId; - aggregations?: Aggregation; - aggs?: Aggregation; - chunking_config?: ChunkingConfig; - frequency?: string; - indices: IndexPatternTitle[]; - indexes?: IndexPatternTitle[]; // The datafeed can contain indexes and indices - job_id: JobId; - query: object; - query_delay?: string; - script_fields?: Record; - runtime_mappings?: RuntimeMappings; - scroll_size?: number; - delayed_data_check_config?: object; - indices_options?: IndicesOptions; -} +export type Datafeed = estypes.Datafeed; +// export interface Datafeed extends estypes.DatafeedConfig { +// runtime_mappings?: RuntimeMappings; +// aggs?: Aggregation; +// } +// export interface Datafeed { +// datafeed_id: DatafeedId; +// aggregations?: Aggregation; +// aggs?: Aggregation; +// chunking_config?: ChunkingConfig; +// frequency?: string; +// indices: IndexPatternTitle[]; +// indexes?: IndexPatternTitle[]; // The datafeed can contain indexes and indices +// job_id: JobId; +// query: object; +// query_delay?: string; +// script_fields?: Record; +// runtime_mappings?: RuntimeMappings; +// scroll_size?: number; +// delayed_data_check_config?: object; +// indices_options?: IndicesOptions; +// } -export interface ChunkingConfig { - mode: 'auto' | 'manual' | 'off'; - time_span?: string; -} +export type ChunkingConfig = estypes.ChunkingConfig; + +// export interface ChunkingConfig { +// mode: 'auto' | 'manual' | 'off'; +// time_span?: string; +// } export type Aggregation = Record< string, @@ -45,9 +53,10 @@ export type Aggregation = Record< } >; -export interface IndicesOptions { - expand_wildcards?: 'all' | 'open' | 'closed' | 'hidden' | 'none'; - ignore_unavailable?: boolean; - allow_no_indices?: boolean; - ignore_throttled?: boolean; -} +export type IndicesOptions = estypes.IndicesOptions; +// export interface IndicesOptions { +// expand_wildcards?: 'all' | 'open' | 'closed' | 'hidden' | 'none'; +// ignore_unavailable?: boolean; +// allow_no_indices?: boolean; +// ignore_throttled?: boolean; +// } diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts index a4b0a5c5c6068..5e1d5e009a764 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { UrlConfig } from '../custom_urls'; import { CREATED_BY_LABEL } from '../../constants/new_job'; @@ -19,79 +20,87 @@ export interface CustomSettings { }; } -export interface Job { - job_id: JobId; - analysis_config: AnalysisConfig; - analysis_limits?: AnalysisLimits; - background_persist_interval?: string; - custom_settings?: CustomSettings; - data_description: DataDescription; - description: string; - groups: string[]; - model_plot_config?: ModelPlotConfig; - model_snapshot_retention_days?: number; - daily_model_snapshot_retention_after_days?: number; - renormalization_window_days?: number; - results_index_name?: string; - results_retention_days?: number; +export type Job = estypes.Job; +// export interface Job { +// job_id: JobId; +// analysis_config: AnalysisConfig; +// analysis_limits?: AnalysisLimits; +// background_persist_interval?: string; +// custom_settings?: CustomSettings; +// data_description: DataDescription; +// description: string; +// groups: string[]; +// model_plot_config?: ModelPlotConfig; +// model_snapshot_retention_days?: number; +// daily_model_snapshot_retention_after_days?: number; +// renormalization_window_days?: number; +// results_index_name?: string; +// results_retention_days?: number; - // optional properties added when the job has been created - create_time?: number; - finished_time?: number; - job_type?: 'anomaly_detector'; - job_version?: string; - model_snapshot_id?: string; - deleting?: boolean; -} +// // optional properties added when the job has been created +// create_time?: number; +// finished_time?: number; +// job_type?: 'anomaly_detector'; +// job_version?: string; +// model_snapshot_id?: string; +// deleting?: boolean; +// } -export interface AnalysisConfig { - bucket_span: BucketSpan; - categorization_field_name?: string; - categorization_filters?: string[]; - categorization_analyzer?: object | string; - detectors: Detector[]; - influencers: string[]; - latency?: number; - multivariate_by_fields?: boolean; - summary_count_field_name?: string; - per_partition_categorization?: PerPartitionCategorization; -} +export type AnalysisConfig = estypes.AnalysisConfig; +// export interface AnalysisConfig { +// bucket_span: BucketSpan; +// categorization_field_name?: string; +// categorization_filters?: string[]; +// categorization_analyzer?: object | string; +// detectors: Detector[]; +// influencers: string[]; +// latency?: number; +// multivariate_by_fields?: boolean; +// summary_count_field_name?: string; +// per_partition_categorization?: PerPartitionCategorization; +// } -export interface Detector { - by_field_name?: string; - detector_description?: string; - detector_index?: number; - exclude_frequent?: string; - field_name?: string; - function: string; - over_field_name?: string; - partition_field_name?: string; - use_null?: boolean; - custom_rules?: CustomRule[]; -} -export interface AnalysisLimits { - categorization_examples_limit?: number; - model_memory_limit: string; -} +export type Detector = estypes.Detector; +// export interface Detector { +// by_field_name?: string; +// detector_description?: string; +// detector_index?: number; +// exclude_frequent?: string; +// field_name?: string; +// function: string; +// over_field_name?: string; +// partition_field_name?: string; +// use_null?: boolean; +// custom_rules?: CustomRule[]; +// } -export interface DataDescription { - format?: string; - time_field: string; - time_format?: string; -} +export type AnalysisLimits = estypes.AnalysisLimits; +// export interface AnalysisLimits { +// categorization_examples_limit?: number; +// model_memory_limit: string; +// } -export interface ModelPlotConfig { - enabled?: boolean; - annotations_enabled?: boolean; - terms?: string; -} +export type DataDescription = estypes.DataDescription; +// export interface DataDescription { +// format?: string; +// time_field: string; +// time_format?: string; +// } +export type ModelPlotConfig = estypes.ModelPlotConfig; +// export interface ModelPlotConfig { +// enabled?: boolean; +// annotations_enabled?: boolean; +// terms?: string; +// } + +export type CustomRule = estypes.DetectionRule; // TODO, finish this when it's needed -export interface CustomRule { - actions: string[]; - scope?: object; - conditions: any[]; -} +// export interface CustomRule { +// actions: string[]; +// scope?: object; +// conditions: any[]; +// } export interface PerPartitionCategorization { enabled?: boolean; diff --git a/x-pack/plugins/ml/common/types/es_client.ts b/x-pack/plugins/ml/common/types/es_client.ts index 0674ec6001159..f6db736db2519 100644 --- a/x-pack/plugins/ml/common/types/es_client.ts +++ b/x-pack/plugins/ml/common/types/es_client.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { SearchResponse, ShardsResponse } from 'elasticsearch'; +import type { SearchResponse, ShardsResponse } from 'elasticsearch'; +import { buildEsQuery } from '../../../../../src/plugins/data/common/es_query/es_query'; +import type { DslQuery } from '../../../../../src/plugins/data/common/es_query/kuery'; +import type { JsonObject } from '../../../../../src/plugins/kibana_utils/common'; export const HITS_TOTAL_RELATION = { EQ: 'eq', @@ -30,3 +33,5 @@ export interface SearchResponse7 { hits: SearchResponse7Hits; aggregations?: any; } + +export type InfluencersFilterQuery = ReturnType | DslQuery | JsonObject; diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index 047852534965c..f9f7f8fc7ead6 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; import { ML_JOB_AGGREGATION, @@ -120,4 +121,4 @@ export interface RuntimeField { }; } -export type RuntimeMappings = Record; +export type RuntimeMappings = estypes.RuntimeFields; diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 766b714abcc98..c7c3f3ae9b280 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -15,6 +15,7 @@ import { ML_PAGES } from '../constants/ml_url_generator'; import type { DataFrameAnalysisConfigType } from './data_frame_analytics'; import type { SearchQueryLanguage } from '../constants/search'; import type { ListingPageUrlState } from './common'; +import type { InfluencersFilterQuery } from './es_client'; type OptionalPageState = object | undefined; @@ -113,9 +114,9 @@ export interface ExplorerAppState { viewByFromPage?: number; }; mlExplorerFilter: { - influencersFilterQuery?: unknown; + influencersFilterQuery?: InfluencersFilterQuery; filterActive?: boolean; - filteredFields?: string[]; + filteredFields?: Array; queryString?: string; }; query?: any; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts index de1adfabcd7da..d8da7b8252771 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.ts +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -27,10 +27,18 @@ export enum ENTITY_FIELD_TYPE { PARTITON = 'partition', } +export const ENTITY_FIELD_OPERATIONS = { + ADD: '+', + REMOVE: '-', +} as const; + +export type EntityFieldOperation = typeof ENTITY_FIELD_OPERATIONS[keyof typeof ENTITY_FIELD_OPERATIONS]; + export interface EntityField { fieldName: string; fieldValue: string | number | undefined; fieldType?: ENTITY_FIELD_TYPE; + operation?: EntityFieldOperation; } // List of function descriptions for which actual values from record level results should be displayed. diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 4b80661f13c09..10f5fb975ef5e 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -733,7 +733,7 @@ export function validateGroupNames(job: Job): ValidationResults { * @return {Duration} the parsed interval, or null if it does not represent a valid * time interval. */ -export function parseTimeIntervalForJob(value: string | undefined): Duration | null { +export function parseTimeIntervalForJob(value: string | number | undefined): Duration | null { if (value === undefined) { return null; } @@ -748,7 +748,7 @@ export function parseTimeIntervalForJob(value: string | undefined): Duration | n // Checks that the value for a field which represents a time interval, // such as a job bucket span or datafeed query delay, is valid. -function isValidTimeInterval(value: string | undefined): boolean { +function isValidTimeInterval(value: string | number | undefined): boolean { if (value === undefined) { return true; } diff --git a/x-pack/plugins/ml/common/util/parse_interval.ts b/x-pack/plugins/ml/common/util/parse_interval.ts index c3013ef447792..6ca280dc12ebd 100644 --- a/x-pack/plugins/ml/common/util/parse_interval.ts +++ b/x-pack/plugins/ml/common/util/parse_interval.ts @@ -34,7 +34,10 @@ const SUPPORT_ES_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h', 'd']; // to work with units less than 'day'. // 3. Fractional intervals e.g. 1.5h or 4.5d are not allowed, in line with the behaviour // of the Elasticsearch date histogram aggregation. -export function parseInterval(interval: string, checkValidEsUnit = false): Duration | null { +export function parseInterval( + interval: string | number, + checkValidEsUnit = false +): Duration | null { const matches = String(interval).trim().match(INTERVAL_STRING_RE); if (!Array.isArray(matches) || matches.length < 3) { return null; diff --git a/x-pack/plugins/ml/public/__mocks__/core_start.ts b/x-pack/plugins/ml/public/__mocks__/core_start.ts new file mode 100644 index 0000000000000..1fd988c887dda --- /dev/null +++ b/x-pack/plugins/ml/public/__mocks__/core_start.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. + */ +import { coreMock } from '../../../../../src/core/public/mocks'; +import { createMlStartDepsMock } from './ml_start_deps'; + +export const createCoreStartMock = () => + coreMock.createSetup({ pluginStartDeps: createMlStartDepsMock() }); diff --git a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts new file mode 100644 index 0000000000000..77381c8728a48 --- /dev/null +++ b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { kibanaLegacyPluginMock } from '../../../../../src/plugins/kibana_legacy/public/mocks'; +import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; +import { lensPluginMock } from '../../../lens/public/mocks'; +import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; + +export const createMlStartDepsMock = () => ({ + data: dataPluginMock.createStartContract(), + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + kibanaLegacy: kibanaLegacyPluginMock.createStartContract(), + uiActions: uiActionsPluginMock.createStartContract(), + spaces: jest.fn(), + embeddable: embeddablePluginMock.createStartContract(), + maps: jest.fn(), + lens: lensPluginMock.createStartContract(), + triggersActionsUi: triggersActionsUiMock.createStart(), + fileUpload: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index e1c4e6b1e53d5..348c400b6d5a9 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -10,7 +10,7 @@ import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { usePageUrlState } from '../../../util/url_state'; -interface TableInterval { +export interface TableInterval { display: string; val: string; } @@ -64,8 +64,16 @@ export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] export const SelectInterval: FC = () => { const [interval, setInterval] = useTableInterval(); - const onChange = (e: React.ChangeEvent) => { - setInterval(optionValueToInterval(e.target.value)); + return ; +}; + +interface SelectIntervalUIProps { + interval: TableInterval; + onChange: (interval: TableInterval) => void; +} +export const SelectIntervalUI: FC = ({ interval, onChange }) => { + const handleOnChange = (e: React.ChangeEvent) => { + onChange(optionValueToInterval(e.target.value)); }; return ( @@ -73,7 +81,7 @@ export const SelectInterval: FC = () => { options={OPTIONS} className="ml-select-interval" value={interval.val} - onChange={onChange} + onChange={handleOnChange} /> ); }; diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index 22076c8215154..e8766ea16c002 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -38,7 +38,7 @@ const optionsMap = { [criticalLabel]: ANOMALY_THRESHOLD.CRITICAL, }; -interface TableSeverity { +export interface TableSeverity { val: number; display: string; color: string; @@ -67,7 +67,7 @@ export const SEVERITY_OPTIONS: TableSeverity[] = [ }, ]; -function optionValueToThreshold(value: number) { +export function optionValueToThreshold(value: number) { // Get corresponding threshold object with required display and val properties from the specified value. let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); @@ -121,17 +121,26 @@ interface Props { export const SelectSeverity: FC = ({ classNames } = { classNames: '' }) => { const [severity, setSeverity] = useTableSeverity(); - const onChange = (valueDisplay: string) => { - setSeverity(optionValueToThreshold(optionsMap[valueDisplay])); + return ; +}; + +export const SelectSeverityUI: FC<{ + classNames?: string; + severity: TableSeverity; + onChange: (s: TableSeverity) => void; +}> = ({ classNames = '', severity, onChange }) => { + const handleOnChange = (valueDisplay: string) => { + onChange(optionValueToThreshold(optionsMap[valueDisplay])); }; return ( ); }; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts new file mode 100644 index 0000000000000..337a49ada6f31 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useUiSettings } from '../../contexts/kibana'; +import { TimeBuckets } from '../../util/time_buckets'; +import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; + +export const useTimeBuckets = () => { + const uiSettings = useUiSettings(); + return useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); +}; diff --git a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js index 9a580c179001d..d52d22f6b4aa7 100644 --- a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js +++ b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js @@ -324,7 +324,7 @@ export function CustomSelectionTable({ isSelected={isItemSelected(item[tableItemId])} isSelectable={true} hasActions={true} - data-test-subj="mlCustomSelectionTableRow" + data-test-subj={`mlCustomSelectionTableRow row-${item[tableItemId]}`} > {cells} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index 69750b0ab1aaa..312776f0d6a07 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -110,6 +110,7 @@ export const getRuntimeFieldsMapping = ( if (isPopulatedObject(ipRuntimeMappings)) { indexPatternFields.forEach((ipField) => { if (ipRuntimeMappings.hasOwnProperty(ipField)) { + // @ts-expect-error combinedRuntimeMappings[ipField] = ipRuntimeMappings[ipField]; } }); diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx index 650a9d3deb539..a79c8a63b3bc6 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EMPTY_FIELD_VALUE_LABEL } from '../../timeseriesexplorer/components/entity_control/entity_control'; import { MLCATEGORY } from '../../../../common/constants/field_types'; +import { ENTITY_FIELD_OPERATIONS } from '../../../../common/util/anomaly_utils'; export type EntityCellFilter = ( entityName: string, @@ -40,7 +41,7 @@ function getAddFilter({ entityName, entityValue, filter }: EntityCellProps) { filter(entityName, entityValue, '+')} + onClick={() => filter(entityName, entityValue, ENTITY_FIELD_OPERATIONS.ADD)} iconType="plusInCircle" aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.addFilterAriaLabel', { defaultMessage: 'Add filter', @@ -65,7 +66,7 @@ function getRemoveFilter({ entityName, entityValue, filter }: EntityCellProps) { filter(entityName, entityValue, '-')} + onClick={() => filter(entityName, entityValue, ENTITY_FIELD_OPERATIONS.REMOVE)} iconType="minusInCircle" aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.removeFilterAriaLabel', { defaultMessage: 'Remove filter', diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts index a3753c8f000ae..4788254e97d1e 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/chart_loader.ts @@ -30,6 +30,7 @@ export function chartLoaderProvider(mlResultsService: MlResultsService) { job.data_counts.earliest_record_timestamp, job.data_counts.latest_record_timestamp, intervalMs, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options job.datafeed_config.indices_options ); if (resp.error !== undefined) { diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts index 80575118e71dc..a1d846c065dce 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts @@ -5,12 +5,21 @@ * 2.0. */ -export const useMlKibana = jest.fn(() => { - return { - services: { - application: { - navigateToApp: jest.fn(), +export const kibanaContextMock = { + services: { + chrome: { recentlyAccessed: { add: jest.fn() } }, + application: { navigateToApp: jest.fn() }, + http: { + basePath: { + get: jest.fn(), }, }, - }; + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + }, +}; + +export const useMlKibana = jest.fn(() => { + return kibanaContextMock; }); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts index dbf78f314b78d..a9ee49fcbadd8 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts @@ -7,7 +7,7 @@ import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter; +export const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter; export const useTimefilter = jest.fn(() => { return timefilterMock; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts index 8d078b59ad778..9690fd1b6c473 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/action_menu/lens_utils.ts @@ -66,7 +66,7 @@ export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: IIn defaultMessage: 'Average of {fieldName}', values: { fieldName: item.fieldName }, }), - operationType: 'avg', + operationType: 'average', sourceField: item.fieldName!, }, col1: { diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 258ffc887325d..e09e9f3d2c1ae 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -13,7 +13,6 @@ import { forkJoin, of, Observable, Subject } from 'rxjs'; import { mergeMap, switchMap, tap } from 'rxjs/operators'; import { useCallback, useMemo } from 'react'; -import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { explorerService } from '../explorer_dashboard_service'; import { getDateFormatTz, @@ -31,10 +30,14 @@ import { import { ExplorerState } from '../reducers'; import { useMlKibana, useTimefilter } from '../../contexts/kibana'; import { AnomalyTimelineService } from '../../services/anomaly_timeline_service'; -import { mlResultsServiceProvider } from '../../services/results_service'; +import { MlResultsService, mlResultsServiceProvider } from '../../services/results_service'; import { isViewBySwimLaneData } from '../swimlane_container'; import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants'; import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; +import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; +import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; +import { mlJobService } from '../../services/job_service'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -52,7 +55,6 @@ const memoize = any>(func: T, context?: any) => { return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual); }; -const memoizedAnomalyDataChange = memoize(anomalyDataChange); const memoizedLoadAnnotationsTableData = memoize( loadAnnotationsTableData ); @@ -64,7 +66,7 @@ const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); export interface LoadExplorerDataConfig { - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; lastRefresh: number; noInfluencersConfigured: boolean; selectedCells: AppStateSelectedCells | undefined; @@ -92,7 +94,9 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi * Fetches the data necessary for the Anomaly Explorer using observables. */ const loadExplorerDataProvider = ( + mlResultsService: MlResultsService, anomalyTimelineService: AnomalyTimelineService, + anomalyExplorerService: AnomalyExplorerChartsService, timefilter: TimefilterContract ) => { const memoizedLoadOverallData = memoize( @@ -103,6 +107,11 @@ const loadExplorerDataProvider = ( anomalyTimelineService.loadViewBySwimlane, anomalyTimelineService ); + const memoizedAnomalyDataChange = memoize( + anomalyExplorerService.getAnomalyData, + anomalyExplorerService + ); + return (config: LoadExplorerDataConfig): Observable> => { if (!isLoadExplorerDataConfig(config)) { return of({}); @@ -124,6 +133,10 @@ const loadExplorerDataProvider = ( viewByPerPage, } = config; + const combinedJobRecords: Record = selectedJobs.reduce((acc, job) => { + return { ...acc, [job.id]: mlJobService.getJob(job.id) }; + }, {}); + const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = getSelectionJobIds(selectedCells, selectedJobs); @@ -149,6 +162,7 @@ const loadExplorerDataProvider = ( ), anomalyChartRecords: memoizedLoadDataForCharts( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -160,6 +174,7 @@ const loadExplorerDataProvider = ( selectionInfluencers.length === 0 ? memoizedLoadTopInfluencers( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -200,23 +215,29 @@ const loadExplorerDataProvider = ( // and pass on the data we already fetched. tap(explorerService.setViewBySwimlaneLoading), // Trigger a side-effect to update the charts. - tap(({ anomalyChartRecords }) => { + tap(({ anomalyChartRecords, topFieldValues }) => { if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { memoizedAnomalyDataChange( lastRefresh, + explorerService, + combinedJobRecords, swimlaneContainerWidth, anomalyChartRecords, timerange.earliestMs, timerange.latestMs, + timefilter, tableSeverity ); } else { memoizedAnomalyDataChange( lastRefresh, + explorerService, + combinedJobRecords, swimlaneContainerWidth, [], timerange.earliestMs, timerange.latestMs, + timefilter, tableSeverity ); } @@ -234,6 +255,7 @@ const loadExplorerDataProvider = ( anomalyChartRecords.length > 0 ? memoizedLoadFilteredTopInfluencers( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -291,12 +313,23 @@ export const useExplorerData = (): [Partial | undefined, (d: any) } = useMlKibana(); const loadExplorerData = useMemo(() => { + const mlResultsService = mlResultsServiceProvider(mlApiServices); const anomalyTimelineService = new AnomalyTimelineService( timefilter, uiSettings, - mlResultsServiceProvider(mlApiServices) + mlResultsService + ); + const anomalyExplorerService = new AnomalyExplorerChartsService( + timefilter, + mlApiServices, + mlResultsService + ); + return loadExplorerDataProvider( + mlResultsService, + anomalyTimelineService, + anomalyExplorerService, + timefilter ); - return loadExplorerDataProvider(anomalyTimelineService, timefilter); }, []); const loadExplorerData$ = useMemo(() => new Subject(), []); diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 2330eafd87825..8fe2c32b766b4 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -25,7 +25,7 @@ import { EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../contexts/kibana'; import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public'; -import { getDefaultPanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { getDefaultSwimlanePanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { useDashboardService } from '../services/dashboard_service'; import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -40,10 +40,10 @@ export interface DashboardItem { export type EuiTableProps = EuiInMemoryTableProps; -function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { +function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) { return { type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, - title: getDefaultPanelTitle(jobIds), + title: getDefaultSwimlanePanelTitle(jobIds), }; } @@ -129,7 +129,7 @@ export const AddToDashboardControl: FC = ({ for (const selectedDashboard of selectedItems) { const panelsData = swimlanes.map((swimlaneType) => { - const config = getDefaultEmbeddablepaPanelConfig(jobIds); + const config = getDefaultEmbeddablePanelConfig(jobIds); if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { return { ...config, diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 4289986bb6a59..7c63d4087ce1e 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -160,7 +160,11 @@ export const AnomalyTimeline: FC = React.memo(
{selectedCells ? ( - + ); -export class Explorer extends React.Component { +export class ExplorerUI extends React.Component { static propTypes = { explorerState: PropTypes.object.isRequired, setSelectedCells: PropTypes.func.isRequired, @@ -224,7 +226,22 @@ export class Explorer extends React.Component { updateLanguage = (language) => this.setState({ language }); render() { - const { showCharts, severity, stoppedPartitions, selectedJobsRunning } = this.props; + const { + share: { + urlGenerators: { getUrlGenerator }, + }, + } = this.props.kibana.services; + + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + + const { + showCharts, + severity, + stoppedPartitions, + selectedJobsRunning, + timefilter, + timeBuckets, + } = this.props; const { annotations, @@ -274,7 +291,6 @@ export class Explorer extends React.Component { const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; return ( @@ -460,7 +476,18 @@ export class Explorer extends React.Component {
- {showCharts && } + {showCharts && ( + + )}
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss new file mode 100644 index 0000000000000..732b71d056536 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss @@ -0,0 +1,14 @@ +.filter-button { + opacity: .3; + min-width: 14px; + padding-right: 0; + + .euiIcon { + width: $euiFontSizeXS; + height: $euiFontSizeXS; + } +} + +.filter-button:hover { + opacity: 1; +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx new file mode 100644 index 0000000000000..079af5827a4b5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { FC } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + ENTITY_FIELD_OPERATIONS, + EntityFieldOperation, +} from '../../../../../../../common/util/anomaly_utils'; +import './entity_filter.scss'; + +interface EntityFilterProps { + onFilter: (params: { + influencerFieldName: string; + influencerFieldValue: string; + action: EntityFieldOperation; + }) => void; + influencerFieldName: string; + influencerFieldValue: string; +} +export const EntityFilter: FC = ({ + onFilter, + influencerFieldName, + influencerFieldValue, +}) => { + return ( + + + } + > + + onFilter({ + influencerFieldName, + influencerFieldValue, + action: ENTITY_FIELD_OPERATIONS.ADD, + }) + } + iconType="plusInCircle" + aria-label={i18n.translate('xpack.ml.entityFilter.addFilterAriaLabel', { + defaultMessage: 'Add filter for {influencerFieldName} {influencerFieldValue}', + values: { influencerFieldName, influencerFieldValue }, + })} + /> + + + } + > + + onFilter({ + influencerFieldName, + influencerFieldValue, + action: ENTITY_FIELD_OPERATIONS.REMOVE, + }) + } + iconType="minusInCircle" + aria-label={i18n.translate('xpack.ml.entityFilter.removeFilterAriaLabel', { + defaultMessage: 'Remove filter for {influencerFieldName} {influencerFieldValue}', + values: { influencerFieldName, influencerFieldValue }, + })} + /> + + + ); +}; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/index.ts similarity index 69% rename from x-pack/plugins/data_enhanced/public/autocomplete/index.ts rename to x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/index.ts index 7910ce3ffb237..69e1a632b5ffd 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/index.ts @@ -5,7 +5,4 @@ * 2.0. */ -export { - setupKqlQuerySuggestionProvider, - KUERY_LANGUAGE_NAME, -} from './providers/kql_query_suggestion'; +export { EntityFilter } from './entity_filter'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js index 97eb73906e8de..ad07d1a75bdb5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js @@ -7,18 +7,20 @@ import './_explorer_chart_label.scss'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Fragment, useCallback } from 'react'; import { EuiIconTip } from '@elastic/eui'; import { ExplorerChartLabelBadge } from './explorer_chart_label_badge'; import { ExplorerChartInfoTooltip } from '../../explorer_chart_info_tooltip'; +import { EntityFilter } from './entity_filter'; export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, wrapLabel = false, + onSelectEntity, }) { // Depending on whether we wrap the entityField badges to a new line, we render this differently: // @@ -37,9 +39,27 @@ export function ExplorerChartLabel({  –  ); - const entityFieldBadges = entityFields.map((entity) => ( - - )); + const applyFilter = useCallback( + ({ influencerFieldName, influencerFieldValue, action }) => + onSelectEntity(influencerFieldName, influencerFieldValue, action), + [onSelectEntity] + ); + + const entityFieldBadges = entityFields.map((entity) => { + const key = `${infoTooltip.chartFunction}-${entity.fieldName}-${entity.fieldType}-${entity.fieldValue}`; + return ( + + + {onSelectEntity !== undefined && ( + + )} + + ); + }); const infoIcon = ( diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx new file mode 100644 index 0000000000000..d1e22ef21de25 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { ExplorerChartsContainer } from './explorer_charts_container'; +import { + SelectSeverityUI, + TableSeverity, +} from '../../components/controls/select_severity/select_severity'; +import type { UrlGeneratorContract } from '../../../../../../../src/plugins/share/public'; +import type { TimeBuckets } from '../../util/time_buckets'; +import type { TimefilterContract } from '../../../../../../../src/plugins/data/public'; +import type { EntityFieldOperation } from '../../../../common/util/anomaly_utils'; +import type { ExplorerChartsData } from './explorer_charts_container_service'; + +interface ExplorerAnomaliesContainerProps { + id: string; + chartsData: ExplorerChartsData; + showCharts: boolean; + severity: TableSeverity; + setSeverity: (severity: TableSeverity) => void; + mlUrlGenerator: UrlGeneratorContract<'ML_APP_URL_GENERATOR'>; + timeBuckets: TimeBuckets; + timefilter: TimefilterContract; + onSelectEntity: (fieldName: string, fieldValue: string, operation: EntityFieldOperation) => void; +} + +const tooManyBucketsCalloutMsg = i18n.translate( + 'xpack.ml.explorer.charts.dashboardTooManyBucketsDescription', + { + defaultMessage: + 'This selection contains too many buckets to be displayed. You should shorten the time range of the view.', + } +); + +export const ExplorerAnomaliesContainer: FC = ({ + id, + chartsData, + showCharts, + severity, + setSeverity, + mlUrlGenerator, + timeBuckets, + timefilter, + onSelectEntity, +}) => { + return ( + <> + + + + + + + + + + {Array.isArray(chartsData.seriesToPlot) && + chartsData.seriesToPlot.length === 0 && + chartsData.errorMessages === undefined && ( + +

+ +

+
+ )} +
+ {showCharts && ( + + )} +
+ + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 4607ac65c87a6..fa6d8acfb0e00 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -33,7 +33,6 @@ import { chartExtendedLimits, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -63,7 +62,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets, tooltipService, timeBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -263,7 +262,6 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index ca8d832e6b43b..11a15b192fc52 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -7,18 +7,6 @@ import { chartData as mockChartData } from './__mocks__/mock_chart_data_rare'; import seriesConfig from './__mocks__/mock_series_config_rare.json'; - -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -30,7 +18,10 @@ import React from 'react'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { chartLimits } from '../../util/chart_utils'; - +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; +const utilityProps = { + timeBuckets: timeBucketsMock, +}; describe('ExplorerChart', () => { const mlSelectSeverityServiceMock = { state: { @@ -55,6 +46,7 @@ describe('ExplorerChart', () => { ); @@ -80,6 +72,7 @@ describe('ExplorerChart', () => { seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} + {...utilityProps} /> ); @@ -112,6 +105,7 @@ describe('ExplorerChart', () => { seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} + {...utilityProps} />
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index d2d81e0349c68..39a3f83961d3a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,7 +38,6 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; const CONTENT_WRAPPER_HEIGHT = 215; @@ -50,6 +49,7 @@ export class ExplorerChartSingleMetric extends React.Component { seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, tooltipService: PropTypes.object.isRequired, + timeBuckets: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets, tooltipService, timeBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -188,7 +188,6 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 25b2251b45435..981f7515d3d70 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -7,18 +7,6 @@ import { chartData as mockChartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; - -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -30,6 +18,11 @@ import React from 'react'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; import { chartLimits } from '../../util/chart_utils'; +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; + +const utilityProps = { + timeBuckets: timeBucketsMock, +}; describe('ExplorerChart', () => { const mlSelectSeverityServiceMock = { @@ -56,6 +49,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} /> ); @@ -82,6 +76,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} /> ); @@ -115,6 +110,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} />
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 35fd168c6c0f2..2432c6671827e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import './_index.scss'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiButtonEmpty, @@ -23,7 +24,6 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; -import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -31,15 +31,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; - +import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: 'This selection contains too many buckets to be displayed. You should shorten the time range of the view or narrow the selection in the timeline.', }); + const textViewButton = i18n.translate( 'xpack.ml.explorer.charts.openInSingleMetricViewerButtonLabel', { @@ -67,14 +67,23 @@ function ExplorerChartContainer({ wrapLabel, mlUrlGenerator, basePath, + timeBuckets, + timefilter, + onSelectEntity, + recentlyAccessed, + tooManyBucketsCalloutMsg, }) { - const [explorerSeriesLink, setExplorerSeriesLink] = useState(); + const [explorerSeriesLink, setExplorerSeriesLink] = useState(''); useEffect(() => { let isCancelled = false; const generateLink = async () => { if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); + const singleMetricViewerLink = await getExploreSeriesLink( + mlUrlGenerator, + series, + timefilter + ); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -85,8 +94,15 @@ function ExplorerChartContainer({ }, [mlUrlGenerator, series]); const addToRecentlyAccessed = useCallback(() => { - addItemToRecentlyAccessed('timeseriesexplorer', series.jobId, explorerSeriesLink); - }, [explorerSeriesLink]); + if (recentlyAccessed) { + addItemToRecentlyAccessed( + 'timeseriesexplorer', + series.jobId, + explorerSeriesLink, + recentlyAccessed + ); + } + }, [explorerSeriesLink, recentlyAccessed]); const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -121,6 +137,7 @@ function ExplorerChartContainer({ entityFields={entityFields} infoTooltip={{ ...series.infoTooltip, chartType }} wrapLabel={wrapLabel} + onSelectEntity={onSelectEntity} /> @@ -128,7 +145,7 @@ function ExplorerChartContainer({ {tooManyBuckets && ( ); } + if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -176,6 +194,7 @@ function ExplorerChartContainer({ {(tooltipService) => ( {(tooltipService) => ( { const { services: { + chrome: { recentlyAccessed }, http: { basePath }, - share: { - urlGenerators: { getUrlGenerator }, - }, embeddable: embeddablePlugin, maps: mapsPlugin, }, @@ -244,8 +267,6 @@ export const ExplorerChartsContainerUI = ({ const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; - const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); - // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; @@ -255,7 +276,7 @@ export const ExplorerChartsContainerUI = ({ return ( <> - + {seriesToUse.length > 0 && seriesToUse.map((series) => ( ))} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index fcaca2bf710f5..53d06e7253f00 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -18,18 +18,10 @@ import { ExplorerChartsContainer } from './explorer_charts_container'; import { chartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; import seriesConfigRare from './__mocks__/mock_series_config_rare.json'; +import { kibanaContextMock } from '../../contexts/kibana/__mocks__/kibana_context'; +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; +import { timefilterMock } from '../../contexts/kibana/__mocks__/use_timefilter'; -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -47,6 +39,18 @@ jest.mock('../../../../../../../src/plugins/kibana_react/public', () => ({ }, })); +const getUtilityProps = () => { + const mlUrlGenerator = { + createUrl: jest.fn(), + }; + return { + mlUrlGenerator, + timefilter: timefilterMock, + timeBuckets: timeBucketsMock, + kibana: kibanaContextMock, + }; +}; + describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; const originalGetBBox = SVGElement.prototype.getBBox; @@ -54,32 +58,15 @@ describe('ExplorerChartsContainer', () => { beforeEach(() => (SVGElement.prototype.getBBox = () => mockedGetBBox)); afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); - const kibanaContextMock = { - services: { - application: { navigateToApp: jest.fn() }, - http: { - basePath: { - get: jest.fn(), - }, - }, - share: { - urlGenerators: { getUrlGenerator: jest.fn() }, - }, - }, - }; test('Minimal Initialization', () => { const wrapper = shallow( - + ); expect(wrapper.html()).toBe( - '
' + '
' ); }); @@ -99,7 +86,7 @@ describe('ExplorerChartsContainer', () => { }; const wrapper = mount( - + ); @@ -127,7 +114,7 @@ describe('ExplorerChartsContainer', () => { }; const wrapper = mount( - + ); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts deleted file mode 100644 index a384a38899587..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { JobId } from '../../../../common/types/anomaly_detection_jobs'; - -export interface ExplorerChartSeriesErrorMessages { - [key: string]: Set; -} -export declare interface ExplorerChartsData { - chartsPerRow: number; - seriesToPlot: any[]; - tooManyBuckets: boolean; - timeFieldName: string; - errorMessages: ExplorerChartSeriesErrorMessages; -} - -export declare const getDefaultChartsData: () => ExplorerChartsData; - -export declare const anomalyDataChange: ( - chartsContainerWidth: number, - anomalyRecords: any[], - earliestMs: number, - latestMs: number, - severity?: number -) => void; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js deleted file mode 100644 index 7eef548bc2d1c..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ /dev/null @@ -1,765 +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. - */ - -/* - * Service for the container for the anomaly charts in the - * Machine Learning Explorer dashboard. - * The service processes the data required to draw each of the charts - * and manages the layout of the charts in the containing div. - */ - -import { get, each, find, sortBy, map, reduce } from 'lodash'; - -import { buildConfig } from './explorer_chart_config_builder'; -import { chartLimits, getChartType } from '../../util/chart_utils'; -import { getTimefilter } from '../../util/dependency_cache'; - -import { getEntityFieldList } from '../../../../common/util/anomaly_utils'; -import { - isSourceDataChartableForDetector, - isModelPlotChartableForDetector, - isModelPlotEnabled, - isMappableJob, -} from '../../../../common/util/job_utils'; -import { mlResultsService } from '../../services/results_service'; -import { mlJobService } from '../../services/job_service'; -import { explorerService } from '../explorer_dashboard_service'; - -import { CHART_TYPE } from '../explorer_constants'; -import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { i18n } from '@kbn/i18n'; -import { SWIM_LANE_LABEL_WIDTH } from '../swimlane_container'; - -export function getDefaultChartsData() { - return { - chartsPerRow: 1, - errorMessages: undefined, - seriesToPlot: [], - // default values, will update on every re-render - tooManyBuckets: false, - timeFieldName: 'timestamp', - }; -} - -const CHART_MAX_POINTS = 500; -const ANOMALIES_MAX_RESULTS = 500; -const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. -const ML_TIME_FIELD_NAME = 'timestamp'; -const USE_OVERALL_CHART_LIMITS = false; -const MAX_CHARTS_PER_ROW = 4; - -export const anomalyDataChange = function ( - chartsContainerWidth, - anomalyRecords, - selectedEarliestMs, - selectedLatestMs, - severity = 0 -) { - const data = getDefaultChartsData(); - - const containerWith = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH; - - const filteredRecords = anomalyRecords.filter((record) => { - return Number(record.record_score) >= severity; - }); - const [allSeriesRecords, errorMessages] = processRecordsForDisplay(filteredRecords); - // Calculate the number of charts per row, depending on the width available, to a max of 4. - let chartsPerRow = Math.min(Math.max(Math.floor(containerWith / 550), 1), MAX_CHARTS_PER_ROW); - if (allSeriesRecords.length === 1) { - chartsPerRow = 1; - } - - data.chartsPerRow = chartsPerRow; - - // Build the data configs of the anomalies to be displayed. - // TODO - implement paging? - // For now just take first 6 (or 8 if 4 charts per row). - const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); - const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); - const hasGeoData = recordsToPlot.find( - (record) => - (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG - ); - - const seriesConfigs = recordsToPlot.map(buildConfig); - const seriesConfigsNoGeoData = []; - - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - - const mapData = []; - - if (hasGeoData !== undefined) { - for (let i = 0; i < seriesConfigs.length; i++) { - const config = seriesConfigs[i]; - let records; - if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { - if (config.entityFields.length) { - records = [ - recordsToPlot.find((record) => { - const entityFieldName = config.entityFields[0].fieldName; - const entityFieldValue = config.entityFields[0].fieldValue; - return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; - }), - ]; - } else { - records = recordsToPlot; - } - - mapData.push({ - ...config, - loading: false, - mapData: records, - }); - } else { - seriesConfigsNoGeoData.push(config); - } - } - } - - // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. - data.tooManyBuckets = false; - const chartWidth = Math.floor(containerWith / chartsPerRow); - const { chartRange, tooManyBuckets } = calculateChartRange( - seriesConfigs, - selectedEarliestMs, - selectedLatestMs, - chartWidth, - recordsToPlot, - data.timeFieldName - ); - data.tooManyBuckets = tooManyBuckets; - - data.errorMessages = errorMessages; - - explorerService.setCharts({ ...data }); - - if (seriesConfigs.length === 0) { - return data; - } - - // Query 1 - load the raw metric data. - function getMetricData(config, range) { - const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config; - - const job = mlJobService.getJob(jobId); - - // If the job uses aggregation or scripted fields, and if it's a config we don't support - // use model plot data if model plot is enabled - // else if source data can be plotted, use that, otherwise model plot will be available. - const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); - if (useSourceData === true) { - const datafeedQuery = get(config, 'datafeedConfig.query', null); - return mlResultsService - .getMetricData( - config.datafeedConfig.indices, - entityFields, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.summaryCountFieldName, - config.timeField, - range.min, - range.max, - bucketSpanSeconds * 1000, - config.datafeedConfig - ) - .toPromise(); - } else { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity = find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } - } - - if (detector.over_field_name !== undefined) { - const overEntity = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } - } - - if (detector.by_field_name !== undefined) { - const byEntity = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } - } - - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - return mlResultsService - .getModelPlotOutput( - jobId, - detectorIndex, - criteriaFields, - range.min, - range.max, - bucketSpanSeconds * 1000 - ) - .toPromise() - .then((resp) => { - // Return data in format required by the explorer charts. - const results = resp.results; - Object.keys(results).forEach((time) => { - obj.results[time] = results[time].actual; - }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - } - } - - // Query 2 - load the anomalies. - // Criteria to return the records for this series are the detector_index plus - // the specific combination of 'entity' fields i.e. the partition / by / over fields. - function getRecordsForCriteria(config, range) { - let criteria = []; - criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); - criteria = criteria.concat(config.entityFields); - return mlResultsService - .getRecordsForCriteria( - [config.jobId], - criteria, - 0, - range.min, - range.max, - ANOMALIES_MAX_RESULTS - ) - .toPromise(); - } - - // Query 3 - load any scheduled events for the job. - function getScheduledEvents(config, range) { - return mlResultsService - .getScheduledEventsByBucket( - [config.jobId], - range.min, - range.max, - config.bucketSpanSeconds * 1000, - 1, - MAX_SCHEDULED_EVENTS - ) - .toPromise(); - } - - // Query 4 - load context data distribution - function getEventDistribution(config, range) { - const chartType = getChartType(config); - - let splitField; - let filterField = null; - - // Define splitField and filterField based on chartType - if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { - splitField = config.entityFields.find((f) => f.fieldType === 'by'); - filterField = config.entityFields.find((f) => f.fieldType === 'partition'); - } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { - splitField = config.entityFields.find((f) => f.fieldType === 'over'); - filterField = config.entityFields.find((f) => f.fieldType === 'partition'); - } - - const datafeedQuery = get(config, 'datafeedConfig.query', null); - return mlResultsService.getEventDistributionData( - config.datafeedConfig.indices, - splitField, - filterField, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, - range.min, - range.max, - config.bucketSpanSeconds * 1000 - ); - } - - // first load and wait for required data, - // only after that trigger data processing and page render. - // TODO - if query returns no results e.g. source data has been deleted, - // display a message saying 'No data between earliest/latest'. - const seriesPromises = []; - // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses - const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; - seriesCongifsForPromises.forEach((seriesConfig) => { - seriesPromises.push( - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); - }); - - function processChartData(response, seriesIndex) { - const metricData = response[0].results; - const records = response[1].records; - const jobId = seriesCongifsForPromises[seriesIndex].jobId; - const scheduledEvents = response[2].events[jobId]; - const eventDistribution = response[3]; - const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); - - // Sort records in ascending time order matching up with chart data - records.sort((recordA, recordB) => { - return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; - }); - - // Return dataset in format used by the chart. - // i.e. array of Objects with keys date (timestamp), value, - // plus anomalyScore for points with anomaly markers. - let chartData = []; - if (metricData !== undefined) { - if (eventDistribution.length > 0 && records.length > 0) { - const filterField = records[0].by_field_value || records[0].over_field_value; - chartData = eventDistribution.filter((d) => d.entity !== filterField); - map(metricData, (value, time) => { - // The filtering for rare/event_distribution charts needs to be handled - // differently because of how the source data is structured. - // For rare chart values we are only interested wether a value is either `0` or not, - // `0` acts like a flag in the chart whether to display the dot/marker. - // All other charts (single metric, population) are metric based and with - // those a value of `null` acts as the flag to hide a data point. - if ( - (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || - (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) - ) { - chartData.push({ - date: +time, - value: value, - entity: filterField, - }); - } - }); - } else { - chartData = map(metricData, (value, time) => ({ - date: +time, - value: value, - })); - } - } - - // Iterate through the anomaly records, adding anomalyScore properties - // to the chartData entries for anomalous buckets. - const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); - each(records, (record) => { - // Look for a chart point with the same time as the record. - // If none found, insert a point for anomalies due to a gap in the data. - const recordTime = record[ML_TIME_FIELD_NAME]; - let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); - if (chartPoint === undefined) { - chartPoint = { date: new Date(recordTime), value: null }; - chartData.push(chartPoint); - } - - chartPoint.anomalyScore = record.record_score; - - if (record.actual !== undefined) { - chartPoint.actual = record.actual; - chartPoint.typical = record.typical; - } else { - const causes = get(record, 'causes', []); - if (causes.length > 0) { - chartPoint.byFieldName = record.by_field_name; - chartPoint.numberOfCauses = causes.length; - if (causes.length === 1) { - // If only a single cause, copy actual and typical values to the top level. - const cause = record.causes[0]; - chartPoint.actual = cause.actual; - chartPoint.typical = cause.typical; - } - } - } - - if (record.multi_bucket_impact !== undefined) { - chartPoint.multiBucketImpact = record.multi_bucket_impact; - } - }); - - // Add a scheduledEvents property to any points in the chart data set - // which correspond to times of scheduled events for the job. - if (scheduledEvents !== undefined) { - each(scheduledEvents, (events, time) => { - const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); - if (chartPoint !== undefined) { - // Note if the scheduled event coincides with an absence of the underlying metric data, - // we don't worry about plotting the event. - chartPoint.scheduledEvents = events; - } - }); - } - - return chartData; - } - - function getChartDataForPointSearch(chartData, record, chartType) { - if ( - chartType === CHART_TYPE.EVENT_DISTRIBUTION || - chartType === CHART_TYPE.POPULATION_DISTRIBUTION - ) { - return chartData.filter((d) => { - return d.entity === (record && (record.by_field_value || record.over_field_value)); - }); - } - - return chartData; - } - - function findChartPointForTime(chartData, time) { - return chartData.find((point) => point.date === time); - } - - Promise.all(seriesPromises) - .then((response) => { - // calculate an overall min/max for all series - const processedData = response.map(processChartData); - const allDataPoints = reduce( - processedData, - (datapoints, series) => { - each(series, (d) => datapoints.push(d)); - return datapoints; - }, - [] - ); - const overallChartLimits = chartLimits(allDataPoints); - - data.seriesToPlot = response.map((d, i) => { - return { - ...seriesCongifsForPromises[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS - ? overallChartLimits - : chartLimits(processedData[i]), - }; - }); - - if (mapData.length) { - // push map data in if it's available - data.seriesToPlot.push(...mapData); - } - explorerService.setCharts({ ...data }); - }) - .catch((error) => { - console.error(error); - }); -}; - -function processRecordsForDisplay(anomalyRecords) { - // Aggregate the anomaly data by detector, and entity (by/over/partition). - if (anomalyRecords.length === 0) { - return [[], undefined]; - } - - // Aggregate by job, detector, and analysis fields (partition, by, over). - const aggregatedData = {}; - - const jobsErrorMessage = {}; - each(anomalyRecords, (record) => { - // Check if we can plot a chart for this record, depending on whether the source data - // is chartable, and if model plot is enabled for the job. - const job = mlJobService.getJob(record.job_id); - - // if we already know this job has datafeed aggregations we cannot support - // no need to do more checks - if (jobsErrorMessage[record.job_id] !== undefined) { - return; - } - - let isChartable = - isSourceDataChartableForDetector(job, record.detector_index) || - isMappableJob(job, record.detector_index); - - if (isChartable === false) { - if (isModelPlotChartableForDetector(job, record.detector_index)) { - // Check if model plot is enabled for this job. - // Need to check the entity fields for the record in case the model plot config has a terms list. - const entityFields = getEntityFieldList(record); - if (isModelPlotEnabled(job, record.detector_index, entityFields)) { - isChartable = true; - } else { - isChartable = false; - jobsErrorMessage[record.job_id] = i18n.translate( - 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage', - { - defaultMessage: - 'source data is not viewable for this detector and model plot is disabled', - } - ); - } - } else { - jobsErrorMessage[record.job_id] = i18n.translate( - 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage', - { - defaultMessage: 'both source data and model plot are not chartable for this detector', - } - ); - } - } - - if (isChartable === false) { - return; - } - const jobId = record.job_id; - if (aggregatedData[jobId] === undefined) { - aggregatedData[jobId] = {}; - } - const detectorsForJob = aggregatedData[jobId]; - - const detectorIndex = record.detector_index; - if (detectorsForJob[detectorIndex] === undefined) { - detectorsForJob[detectorIndex] = {}; - } - - // TODO - work out how best to display results from detectors with just an over field. - const firstFieldName = - record.partition_field_name || record.by_field_name || record.over_field_name; - const firstFieldValue = - record.partition_field_value || record.by_field_value || record.over_field_value; - if (firstFieldName !== undefined) { - const groupsForDetector = detectorsForJob[detectorIndex]; - - if (groupsForDetector[firstFieldName] === undefined) { - groupsForDetector[firstFieldName] = {}; - } - const valuesForGroup = groupsForDetector[firstFieldName]; - if (valuesForGroup[firstFieldValue] === undefined) { - valuesForGroup[firstFieldValue] = {}; - } - - const dataForGroupValue = valuesForGroup[firstFieldValue]; - - let isSecondSplit = false; - if (record.partition_field_name !== undefined) { - const splitFieldName = record.over_field_name || record.by_field_name; - if (splitFieldName !== undefined) { - isSecondSplit = true; - } - } - - if (isSecondSplit === false) { - if (dataForGroupValue.maxScoreRecord === undefined) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForGroupValue.maxScore) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } - } - } else { - // Aggregate another level for the over or by field. - const secondFieldName = record.over_field_name || record.by_field_name; - const secondFieldValue = record.over_field_value || record.by_field_value; - - if (dataForGroupValue[secondFieldName] === undefined) { - dataForGroupValue[secondFieldName] = {}; - } - - const splitsForGroup = dataForGroupValue[secondFieldName]; - if (splitsForGroup[secondFieldValue] === undefined) { - splitsForGroup[secondFieldValue] = {}; - } - - const dataForSplitValue = splitsForGroup[secondFieldValue]; - if (dataForSplitValue.maxScoreRecord === undefined) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForSplitValue.maxScore) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } - } - } - } else { - // Detector with no partition or by field. - const dataForDetector = detectorsForJob[detectorIndex]; - if (dataForDetector.maxScoreRecord === undefined) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } else { - if (record.record_score > dataForDetector.maxScore) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } - } - } - }); - - // Group job id by error message instead of by job: - const errorMessages = {}; - Object.keys(jobsErrorMessage).forEach((jobId) => { - const msg = jobsErrorMessage[jobId]; - if (errorMessages[msg] === undefined) { - errorMessages[msg] = new Set([jobId]); - } else { - errorMessages[msg].add(jobId); - } - }); - let recordsForSeries = []; - // Convert to an array of the records with the highest record_score per unique series. - each(aggregatedData, (detectorsForJob) => { - each(detectorsForJob, (groupsForDetector) => { - if (groupsForDetector.errorMessage !== undefined) { - recordsForSeries.push(groupsForDetector.errorMessage); - } else { - if (groupsForDetector.maxScoreRecord !== undefined) { - // Detector with no partition / by field. - recordsForSeries.push(groupsForDetector.maxScoreRecord); - } else { - each(groupsForDetector, (valuesForGroup) => { - each(valuesForGroup, (dataForGroupValue) => { - if (dataForGroupValue.maxScoreRecord !== undefined) { - recordsForSeries.push(dataForGroupValue.maxScoreRecord); - } else { - // Second level of aggregation for partition and by/over. - each(dataForGroupValue, (splitsForGroup) => { - each(splitsForGroup, (dataForSplitValue) => { - recordsForSeries.push(dataForSplitValue.maxScoreRecord); - }); - }); - } - }); - }); - } - } - }); - }); - recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse(); - - return [recordsForSeries, errorMessages]; -} - -function calculateChartRange( - seriesConfigs, - selectedEarliestMs, - selectedLatestMs, - chartWidth, - recordsToPlot, - timeFieldName -) { - let tooManyBuckets = false; - // Calculate the time range for the charts. - // Fit in as many points in the available container width plotted at the job bucket span. - // Look for the chart with the shortest bucket span as this determines - // the length of the time range that can be plotted. - const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2); - const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; - const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; - - const pointsToPlotFullSelection = Math.ceil( - (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs - ); - - // Optimally space points 5px apart. - const optimumPointSpacing = 5; - const optimumNumPoints = chartWidth / optimumPointSpacing; - - // Increase actual number of points if we can't plot the selected range - // at optimal point spacing. - const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); - const halfPoints = Math.ceil(plotPoints / 2); - const timefilter = getTimefilter(); - const bounds = timefilter.getActiveBounds(); - const boundsMin = bounds.min.valueOf(); - - let chartRange = { - min: Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin), - max: Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()), - }; - - if (plotPoints > CHART_MAX_POINTS) { - // For each series being plotted, display the record with the highest score if possible. - const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS; - let minMs = recordsToPlot[0][timeFieldName]; - let maxMs = recordsToPlot[0][timeFieldName]; - - each(recordsToPlot, (record) => { - const diffMs = maxMs - minMs; - if (diffMs < maxTimeSpan) { - const recordTime = record[timeFieldName]; - if (recordTime < minMs) { - if (maxMs - recordTime <= maxTimeSpan) { - minMs = recordTime; - } - } - - if (recordTime > maxMs) { - if (recordTime - minMs <= maxTimeSpan) { - maxMs = recordTime; - } - } - } - }); - - if (maxMs - minMs < maxTimeSpan) { - // Expand out before and after the span with the highest scoring anomalies, - // covering as much as the requested time span as possible. - // Work out if the high scoring region is nearer the start or end of the selected time span. - const diff = maxTimeSpan - (maxMs - minMs); - if (minMs - 0.5 * diff <= selectedEarliestMs) { - minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff); - maxMs = minMs + maxTimeSpan; - } else { - maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff); - minMs = maxMs - maxTimeSpan; - } - } - - chartRange = { min: minMs, max: maxMs }; - } - - // Elasticsearch aggregation returns points at start of bucket, - // so align the min to the length of the longest bucket. - chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; - if (chartRange.min < boundsMin) { - chartRange.min = chartRange.min + maxBucketSpanMs; - } - - if ( - (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && - chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs - ) { - tooManyBuckets = true; - } - - return { - chartRange, - tooManyBuckets, - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js deleted file mode 100644 index a2ad8efac67b4..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js +++ /dev/null @@ -1,194 +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 { cloneDeep } from 'lodash'; - -import mockAnomalyChartRecords from './__mocks__/mock_anomaly_chart_records.json'; -import mockDetectorsByJob from './__mocks__/mock_detectors_by_job.json'; -import mockJobConfig from './__mocks__/mock_job_config.json'; -import mockSeriesPromisesResponse from './__mocks__/mock_series_promises_response.json'; - -// Some notes on the tests and mocks: -// -// 'call anomalyChangeListener with actual series config' -// This test uses the standard mocks and uses the data as is provided via the mock files. -// The mocked services check for values in the data (e.g. 'mock-job-id', 'farequote-2017') -// and return the mock data from the files. -// -// 'filtering should skip values of null' -// This is is used to verify that values of `null` get filtered out but `0` is kept. -// The test clones mock data from files and adjusts job_id and indices to trigger -// suitable responses from the mocked services. The mocked services check against the -// provided alternative values and return specific modified mock responses for the test case. - -const mockJobConfigClone = cloneDeep(mockJobConfig); - -// adjust mock data to tests against null/0 values -const mockMetricClone = cloneDeep(mockSeriesPromisesResponse[0][0]); -mockMetricClone.results['1486712700000'] = null; -mockMetricClone.results['1486713600000'] = 0; - -jest.mock('../../services/job_service', () => ({ - mlJobService: { - getJob(jobId) { - // this is for 'call anomalyChangeListener with actual series config' - if (jobId === 'mock-job-id') { - return mockJobConfig; - } - // this is for 'filtering should skip values of null' - mockJobConfigClone.datafeed_config.indices = [`farequote-2017-${jobId}`]; - return mockJobConfigClone; - }, - detectorsByJob: mockDetectorsByJob, - }, -})); - -jest.mock('../../services/results_service', () => { - const { of } = require('rxjs'); - return { - mlResultsService: { - getMetricData(indices) { - // this is for 'call anomalyChangeListener with actual series config' - if (indices[0] === 'farequote-2017') { - return of(mockSeriesPromisesResponse[0][0]); - } - // this is for 'filtering should skip values of null' - return of(mockMetricClone); - }, - getRecordsForCriteria() { - return of(mockSeriesPromisesResponse[0][1]); - }, - getScheduledEventsByBucket() { - return of(mockSeriesPromisesResponse[0][2]); - }, - getEventDistributionData(indices) { - // this is for 'call anomalyChangeListener with actual series config' - if (indices[0] === 'farequote-2017') { - return Promise.resolve([]); - } - // this is for 'filtering should skip values of null' and - // resolves with a dummy object to trigger the processing - // of the event distribution chartdata filtering - return Promise.resolve([ - { - entity: 'mock', - }, - ]); - }, - }, - }; -}); - -jest.mock('../../util/string_utils', () => ({ - mlEscape(d) { - return d; - }, -})); - -jest.mock('../../util/dependency_cache', () => { - const dateMath = require('@elastic/datemath'); - let _time = undefined; - const timefilter = { - setTime: (time) => { - _time = time; - }, - getActiveBounds: () => { - return { - min: dateMath.parse(_time.from), - max: dateMath.parse(_time.to), - }; - }, - }; - return { - getTimefilter: () => timefilter, - }; -}); - -jest.mock('../explorer_dashboard_service', () => ({ - explorerService: { - setCharts: jest.fn(), - }, -})); - -import moment from 'moment'; -import { anomalyDataChange, getDefaultChartsData } from './explorer_charts_container_service'; -import { explorerService } from '../explorer_dashboard_service'; -import { getTimefilter } from '../../util/dependency_cache'; - -const timefilter = getTimefilter(); -timefilter.setTime({ - from: moment(1486425600000).toISOString(), // Feb 07 2017 - to: moment(1486857600000).toISOString(), // Feb 12 2017 -}); - -describe('explorerChartsContainerService', () => { - afterEach(() => { - explorerService.setCharts.mockClear(); - }); - - test('call anomalyChangeListener with empty series config', (done) => { - anomalyDataChange(1140, [], 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(1); - expect(explorerService.setCharts.mock.calls[0][0]).toStrictEqual({ - ...getDefaultChartsData(), - chartsPerRow: 2, - }); - done(); - }); - }); - - test('call anomalyChangeListener with actual series config', (done) => { - anomalyDataChange(1140, mockAnomalyChartRecords, 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - expect(explorerService.setCharts.mock.calls[0][0]).toMatchSnapshot(); - expect(explorerService.setCharts.mock.calls[1][0]).toMatchSnapshot(); - done(); - }); - }); - - test('filtering should skip values of null', (done) => { - const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords).map((d) => { - d.job_id = 'mock-job-id-distribution'; - return d; - }); - - anomalyDataChange(1140, mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - expect(explorerService.setCharts.mock.calls[0][0].seriesToPlot.length).toBe(1); - expect(explorerService.setCharts.mock.calls[1][0].seriesToPlot.length).toBe(1); - - // the mock source dataset has a length of 115. one data point has a value of `null`, - // and another one `0`. the received dataset should have a length of 114, - // it should remove the datapoint with `null` and keep the one with `0`. - const chartData = explorerService.setCharts.mock.calls[1][0].seriesToPlot[0].chartData; - expect(chartData).toHaveLength(114); - expect(chartData.filter((d) => d.value === 0)).toHaveLength(1); - expect(chartData.filter((d) => d.value === null)).toHaveLength(0); - done(); - }); - }); - - test('field value with trailing dot should not throw an error', (done) => { - const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords); - mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; - - expect(() => { - anomalyDataChange(1140, mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - }).not.toThrow(); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - done(); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts new file mode 100644 index 0000000000000..aa2eabbd4a38e --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Service for the container for the anomaly charts in the + * Machine Learning Explorer dashboard. + * The service processes the data required to draw each of the charts + * and manages the layout of the charts in the containing div. + */ + +import type { JobId } from '../../../../common/types/anomaly_detection_jobs'; +import { SeriesConfigWithMetadata } from '../../services/anomaly_explorer_charts_service'; + +export interface ExplorerChartSeriesErrorMessages { + [key: string]: Set; +} +export declare interface ExplorerChartsData { + chartsPerRow: number; + seriesToPlot: SeriesConfigWithMetadata[]; + tooManyBuckets: boolean; + timeFieldName: string; + errorMessages: ExplorerChartSeriesErrorMessages | undefined; +} + +export function getDefaultChartsData(): ExplorerChartsData { + return { + chartsPerRow: 1, + errorMessages: undefined, + seriesToPlot: [], + // default values, will update on every re-render + tooManyBuckets: false, + timeFieldName: 'timestamp', + }; +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 4ad0041df73e4..125ccf38b784d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -50,7 +50,9 @@ export const CHART_TYPE = { POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', GEO_MAP: 'geo_map', -}; +} as const; + +export type ChartType = typeof CHART_TYPE[keyof typeof CHART_TYPE]; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index cf632ce41ae3f..49707bc927361 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -156,3 +156,5 @@ export const explorerService = { explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload }); }, }; + +export type ExplorerService = typeof explorerService; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 855106801cbb1..9e24a4349584e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -5,11 +5,13 @@ * 2.0. */ -import { Moment } from 'moment'; - import { AnnotationsTable } from '../../../common/types/annotations'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { SwimlaneType } from './explorer_constants'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { RecordForInfluencer } from '../services/results_service/results_service'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { MlResultsService } from '../services/results_service'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -31,6 +33,10 @@ export declare interface SwimlaneData { interval: number; } +interface ChartRecord extends RecordForInfluencer { + function: string; +} + export declare interface OverallSwimlaneData extends SwimlaneData { earliest: number; latest: number; @@ -98,11 +104,6 @@ export declare interface ExplorerJob { export declare const createJobs: (jobs: CombinedJob[]) => ExplorerJob[]; -export declare interface TimeRangeBounds { - min: Moment | undefined; - max: Moment | undefined; -} - declare interface SwimlaneBounds { earliest: number; latest: number; @@ -132,17 +133,20 @@ export declare const loadAnomaliesTableData: ( fieldName: string, tableInterval: string, tableSeverity: number, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ) => Promise; export declare const loadDataForCharts: ( + mlResultsService: MlResultsService, jobIds: string[], earliestMs: number, latestMs: number, influencers: any[], selectedCells: AppStateSelectedCells | undefined, - influencersFilterQuery: any -) => Promise; + influencersFilterQuery: InfluencersFilterQuery, + // choose whether or not to keep track of the request that could be out of date + takeLatestOnly: boolean +) => Promise; export declare const loadFilteredTopInfluencers: ( jobIds: string[], @@ -151,10 +155,11 @@ export declare const loadFilteredTopInfluencers: ( records: any[], influencers: any[], noInfluencersConfigured: boolean, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ) => Promise; export declare const loadTopInfluencers: ( + mlResultsService: MlResultsService, selectedJobIds: string[], earliestMs: number, latestMs: number, @@ -178,7 +183,7 @@ export declare const loadViewByTopFieldValuesForSelectedTime: ( ) => Promise; export declare interface FilterData { - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; filterActive: boolean; filteredFields: string[]; queryString: string; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index 2f19cbc80f055..ea101d104f783 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -26,7 +26,6 @@ import { import { parseInterval } from '../../../common/util/parse_interval'; import { ml } from '../services/ml_api_service'; import { mlJobService } from '../services/job_service'; -import { mlResultsService } from '../services/results_service'; import { getTimeBucketsFromCache } from '../util/time_buckets'; import { getTimefilter, getUiSettings } from '../util/dependency_cache'; @@ -65,6 +64,7 @@ export function getDefaultSwimlaneData() { } export async function loadFilteredTopInfluencers( + mlResultsService, jobIds, earliestMs, latestMs, @@ -125,6 +125,7 @@ export async function loadFilteredTopInfluencers( }); return await loadTopInfluencers( + mlResultsService, jobIds, earliestMs, latestMs, @@ -539,12 +540,17 @@ export async function loadAnomaliesTableData( // and avoid race conditions ending up with the wrong charts. let requestCount = 0; export async function loadDataForCharts( + mlResultsService, jobIds, earliestMs, latestMs, influencers = [], selectedCells, - influencersFilterQuery + influencersFilterQuery, + // choose whether or not to keep track of the request that could be out of date + // in Anomaly Explorer this is being used to ignore any request that are out of date + // but in embeddables, we might have multiple requests coming from multiple different panels + takeLatestOnly = true ) { return new Promise((resolve) => { // Just skip doing the request when this function @@ -573,7 +579,7 @@ export async function loadDataForCharts( ) .then((resp) => { // Ignore this response if it's returned by an out of date promise - if (newRequestCount < requestCount) { + if (takeLatestOnly && newRequestCount < requestCount) { resolve([]); } @@ -590,6 +596,7 @@ export async function loadDataForCharts( } export async function loadTopInfluencers( + mlResultsService, selectedJobIds, earliestMs, latestMs, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 5d168c7827525..bb90fedfc2315 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -24,6 +24,7 @@ import { } from '../../explorer_utils'; import { AnnotationsTable } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; +import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export interface ExplorerState { annotations: AnnotationsTable; @@ -33,7 +34,7 @@ export interface ExplorerState { filteredFields: any[]; filterPlaceHolder: any; indexPattern: { title: string; fields: any[] }; - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; influencers: Dictionary; isAndOperator: boolean; loading: boolean; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 8deffa15cd6bd..4adb79f065cd4 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -26,6 +26,8 @@ import { HeatmapSpec, TooltipSettings, HeatmapBrushEvent, + Position, + ScaleType, } from '@elastic/charts'; import moment from 'moment'; @@ -44,6 +46,15 @@ import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { useUiSettings } from '../contexts/kibana'; +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -350,9 +361,9 @@ export const SwimlaneContainer: FC = ({ = ({ }} grow={false} > -
+
{showSwimlane && !isLoading && ( = ({ valueAccessor="value" highlightedData={highlightedData} valueFormatter={getFormattedSeverityScore} - xScaleType="time" + xScaleType={ScaleType.Time} ySortPredicate="dataIndex" config={swimLaneConfig} /> diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx index 8b205d2b8d5a1..18297d06dd6fe 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_pagination.tsx @@ -57,6 +57,7 @@ export const SwimLanePagination: FC = ({ closePopover(); setPerPage(v); }} + data-test-subj={`${v} rows`} > = ({ iconType="arrowDown" iconSide="right" onClick={onButtonClick} + data-test-subj="mlSwimLanePageSizeControl" > - + + + } isOpen={isPopoverOpen} closePopover={closePopover} panelPaddingSize="none" > - + @@ -102,6 +106,7 @@ export const SwimLanePagination: FC = ({ pageCount={pageCount} activePage={componentFromPage} onPageClick={goToPage} + data-test-subj="mlSwimLanePagination" /> diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts index da5cfc53b7950..aac36f3e4f573 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { SavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { JobCreator } from './job_creator'; @@ -28,7 +29,7 @@ export interface RichDetector { byField: SplitField; overField: SplitField; partitionField: SplitField; - excludeFrequent: string | null; + excludeFrequent: estypes.ExcludeFrequent | null; description: string | null; customRules: CustomRule[] | null; } @@ -56,7 +57,7 @@ export class AdvancedJobCreator extends JobCreator { byField: SplitField, overField: SplitField, partitionField: SplitField, - excludeFrequent: string | null, + excludeFrequent: estypes.ExcludeFrequent | null, description: string | null ) { // addDetector doesn't support adding new custom rules. @@ -83,7 +84,7 @@ export class AdvancedJobCreator extends JobCreator { byField: SplitField, overField: SplitField, partitionField: SplitField, - excludeFrequent: string | null, + excludeFrequent: estypes.ExcludeFrequent | null, description: string | null, index: number ) { @@ -114,7 +115,7 @@ export class AdvancedJobCreator extends JobCreator { byField: SplitField, overField: SplitField, partitionField: SplitField, - excludeFrequent: string | null, + excludeFrequent: estypes.ExcludeFrequent | null, description: string | null, customRules: CustomRule[] | null ): { detector: Detector; richDetector: RichDetector } { @@ -181,6 +182,8 @@ export class AdvancedJobCreator extends JobCreator { index: this._indexPatternTitle, timeFieldName: this.timeFieldName, query: this.query, + runtimeMappings: this.datafeedConfig.runtime_mappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options indicesOptions: this.datafeedConfig.indices_options, }); this.setTimeRange(start.epoch, end.epoch); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index ec5cb59964ffd..13d46faaf21cf 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -246,12 +246,20 @@ export class JobCreator { private _initModelPlotConfig() { // initialize configs to false if they are missing if (this._job_config.model_plot_config === undefined) { - this._job_config.model_plot_config = {}; + this._job_config.model_plot_config = { + enabled: false, + }; } - if (this._job_config.model_plot_config.enabled === undefined) { + if ( + this._job_config.model_plot_config !== undefined && + this._job_config.model_plot_config.enabled === undefined + ) { this._job_config.model_plot_config.enabled = false; } - if (this._job_config.model_plot_config.annotations_enabled === undefined) { + if ( + this._job_config.model_plot_config !== undefined && + this._job_config.model_plot_config.annotations_enabled === undefined + ) { this._job_config.model_plot_config.annotations_enabled = false; } } @@ -636,6 +644,7 @@ export class JobCreator { this._job_config.custom_settings !== undefined && this._job_config.custom_settings[setting] !== undefined ) { + // @ts-expect-error return this._job_config.custom_settings[setting]; } return null; @@ -710,6 +719,7 @@ export class JobCreator { this._datafeed_config.runtime_mappings = {}; } Object.entries(runtimeFieldMap).forEach(([key, val]) => { + // @ts-expect-error this._datafeed_config.runtime_mappings![key] = val; }); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/default_configs.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/default_configs.ts index 201a304fd3356..bf354b8ad984f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/default_configs.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/default_configs.ts @@ -11,6 +11,7 @@ import { Job, Datafeed, Detector } from '../../../../../../../common/types/anoma import { splitIndexPatternNames } from '../../../../../../../common/util/job_utils'; export function createEmptyJob(): Job { + // @ts-expect-error return { job_id: '', description: '', @@ -27,6 +28,7 @@ export function createEmptyJob(): Job { } export function createEmptyDatafeed(indexPatternTitle: IndexPatternTitle): Datafeed { + // @ts-expect-error return { datafeed_id: '', job_id: '', diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts index 43e7d4e45b6e0..c67a93c5e0626 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts @@ -9,6 +9,7 @@ import { Job, Datafeed } from '../../../../../../../common/types/anomaly_detecti import { filterRuntimeMappings } from './filter_runtime_mappings'; function getJob(): Job { + // @ts-expect-error return { job_id: 'test', description: '', @@ -53,12 +54,14 @@ function getDatafeed(): Datafeed { runtime_mappings: { responsetime_big: { type: 'double', + // @ts-expect-error @elastic/elasticsearch StoredScript.language is required script: { source: "emit(doc['responsetime'].value * 100.0)", }, }, airline_lower: { type: 'keyword', + // @ts-expect-error @elastic/elasticsearch StoredScript.language is required script: { source: "emit(doc['airline'].value.toLowerCase())", }, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts index 5319cd3c3aabc..bfed2d811e206 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts @@ -13,6 +13,7 @@ import type { RuntimeMappings } from '../../../../../../../common/types/fields'; import type { Datafeed, Job } from '../../../../../../../common/types/anomaly_detection_jobs'; +import { isPopulatedObject } from '../../../../../../../common/util/object_utils'; interface Response { runtime_mappings: RuntimeMappings; @@ -20,7 +21,10 @@ interface Response { } export function filterRuntimeMappings(job: Job, datafeed: Datafeed): Response { - if (datafeed.runtime_mappings === undefined) { + if ( + datafeed.runtime_mappings === undefined || + isPopulatedObject(datafeed.runtime_mappings) === false + ) { return { runtime_mappings: {}, discarded_mappings: {}, @@ -71,13 +75,18 @@ function findFieldsInJob(job: Job, datafeed: Datafeed) { findFieldsInAgg(aggs).forEach((f) => usedFields.add(f)); } + const query = datafeed.query; + if (query !== undefined) { + findFieldsInQuery(query).forEach((f) => usedFields.add(f)); + } + return [...usedFields]; } -function findFieldsInAgg(obj: Record) { +function findFieldsInAgg(obj: Record) { const fields: string[] = []; Object.entries(obj).forEach(([key, val]) => { - if (typeof val === 'object' && val !== null) { + if (isPopulatedObject(val)) { fields.push(...findFieldsInAgg(val)); } else if (typeof val === 'string' && key === 'field') { fields.push(val); @@ -86,6 +95,22 @@ function findFieldsInAgg(obj: Record) { return fields; } +function findFieldsInQuery(obj: object) { + const fields: string[] = []; + Object.entries(obj).forEach(([key, val]) => { + // return all nested keys in the object + // most will not be fields, but better to catch everything + // and not accidentally remove a used runtime field. + if (isPopulatedObject(val)) { + fields.push(key); + fields.push(...findFieldsInQuery(val)); + } else { + fields.push(key); + } + }); + return fields; +} + function createMappings(rm: RuntimeMappings, usedFieldNames: string[]) { return { runtimeMappings: usedFieldNames.reduce((acc, cur) => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts index 641eda3dbf3e8..b51cd9b99792b 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -52,6 +52,7 @@ export class CategorizationExamplesLoader { this._jobCreator.end, analyzer, this._jobCreator.runtimeMappings ?? undefined, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options this._jobCreator.datafeedConfig.indices_options ); return resp; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts index a01581f7526c5..6d5fa26af7024 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts @@ -257,6 +257,7 @@ export class ResultsLoader { const fieldValues = await this._chartLoader.loadFieldExampleValues( this._jobCreator.splitField, this._jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options this._jobCreator.datafeedConfig.indices_options ); if (fieldValues.length > 0) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx index 751f12e14f6b5..10c160f58ff77 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import React, { FC, Fragment, useState, useContext, useEffect } from 'react'; import { EuiComboBox, @@ -170,6 +171,7 @@ export const AdvancedDetectorModal: FC = ({ byField, overField, partitionField, + // @ts-expect-error excludeFrequent: excludeFrequentOption.label !== '' ? excludeFrequentOption.label : null, description: descriptionOption !== '' ? descriptionOption : null, customRules: null, @@ -343,7 +345,9 @@ function createFieldOption(field: Field | null): EuiComboBoxOptionOption { }; } -function createExcludeFrequentOption(excludeFrequent: string | null): EuiComboBoxOptionOption { +function createExcludeFrequentOption( + excludeFrequent: estypes.ExcludeFrequent | null +): EuiComboBoxOptionOption { if (excludeFrequent === null) { return emptyOption; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts index 85083146c1378..113bde6fbf93d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts @@ -42,6 +42,7 @@ export function useEstimateBucketSpan() { splitField: undefined, timeField: mlContext.currentIndexPattern.timeFieldName, runtimeMappings: jobCreator.runtimeMappings ?? undefined, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options indicesOptions: jobCreator.datafeedConfig.indices_options, }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx index da9f306cf30e6..f3396a95738a6 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx @@ -55,6 +55,7 @@ export const CategorizationDetectorsSummary: FC = () => { jobCreator.start, jobCreator.end, chartInterval.getInterval().asMilliseconds(), + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); setEventRateChartData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx index 46eb4b88d0518..0515496469030 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -114,6 +114,7 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { .loadFieldExampleValues( splitField, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ) .then(setFieldValues) @@ -145,6 +146,7 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { fieldValues.length > 0 ? fieldValues[0] : null, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx index a4c344d16482b..dc76fc0178112 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx @@ -44,6 +44,7 @@ export const MultiMetricDetectorsSummary: FC = () => { const tempFieldValues = await chartLoader.loadFieldExampleValues( jobCreator.splitField, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); setFieldValues(tempFieldValues); @@ -78,6 +79,7 @@ export const MultiMetricDetectorsSummary: FC = () => { fieldValues.length > 0 ? fieldValues[0] : null, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); setLineChartsData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx index a7eaaff611183..7f5a06925c7e8 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -161,6 +161,7 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { jobCreator.splitField, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); @@ -184,6 +185,7 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { fields: await chartLoader.loadFieldExampleValues( field, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ), }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx index 55a9d37d1115c..31b436944a5b0 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx @@ -79,6 +79,7 @@ export const PopulationDetectorsSummary: FC = () => { jobCreator.splitField, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); @@ -102,6 +103,7 @@ export const PopulationDetectorsSummary: FC = () => { fields: await chartLoader.loadFieldExampleValues( field, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ), }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx index 0e09a81908e83..c5c5cd4d8b744 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -94,6 +94,7 @@ export const SingleMetricDetectors: FC = ({ setIsValid }) => { null, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); if (resp[DTR_IDX] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx index ced94b2095f72..5e64f4ef18984 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx @@ -60,6 +60,7 @@ export const SingleMetricDetectorsSummary: FC = () => { null, cs.intervalMs, jobCreator.runtimeMappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); if (resp[DTR_IDX] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/time_range_step/time_range.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/time_range_step/time_range.tsx index d2cf6b7a00471..b57fd45019abe 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/time_range_step/time_range.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/time_range_step/time_range.tsx @@ -48,6 +48,7 @@ export const TimeRangeStep: FC = ({ setCurrentStep, isCurrentStep }) jobCreator.start, jobCreator.end, chartInterval.getInterval().asMilliseconds(), + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options jobCreator.datafeedConfig.indices_options ); setEventRateChartData(resp); diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index e65ca22effd76..b651b311f13aa 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -37,6 +37,7 @@ import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { useExplorerUrlState } from '../../explorer/hooks/use_explorer_url_state'; +import { useTimeBuckets } from '../../components/custom_hooks/use_time_buckets'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -84,6 +85,8 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); + + const timeBuckets = useTimeBuckets(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -265,6 +268,8 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim stoppedPartitions, invalidTimeRangeError, selectedJobsRunning, + timeBuckets, + timefilter, }} />
diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index b6ad6b015a085..c06094b44f4a0 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -17,7 +17,7 @@ import { NavigateToPath, useNotifications } from '../../contexts/kibana'; import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; import { TimeSeriesExplorer } from '../../timeseriesexplorer'; -import { getDateFormatTz, TimeRangeBounds } from '../../explorer/explorer_utils'; +import { getDateFormatTz } from '../../explorer/explorer_utils'; import { ml } from '../../services/ml_api_service'; import { mlJobService } from '../../services/job_service'; import { mlForecastService } from '../../services/forecast_service'; @@ -43,7 +43,8 @@ import { useToastNotificationService } from '../../services/toast_notification_s import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { useTimeSeriesExplorerUrlState } from '../../timeseriesexplorer/hooks/use_timeseriesexplorer_url_state'; -import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import type { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import type { TimeRangeBounds } from '../../util/time_buckets'; export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts new file mode 100644 index 0000000000000..e36f8985f8ffe --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyDetectorServiceMock = () => ({ + getJobs$: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts new file mode 100644 index 0000000000000..21f07ed9e5a3c --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyExplorerChartsServiceMock = () => ({ + getCombinedJobs: jest.fn(), + getAnomalyData: jest.fn(), + setTimeRange: jest.fn(), + getTimeBounds: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts new file mode 100644 index 0000000000000..b63ae2f859b65 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mlApiServicesMock = { + jobs: { + jobForCloning: jest.fn(), + }, +}; diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts new file mode 100644 index 0000000000000..36e18b49cfa84 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnomalyExplorerChartsService } from './anomaly_explorer_charts_service'; +import mockAnomalyChartRecords from '../explorer/explorer_charts/__mocks__/mock_anomaly_chart_records.json'; +import mockJobConfig from '../explorer/explorer_charts/__mocks__/mock_job_config.json'; +import mockSeriesPromisesResponse from '../explorer/explorer_charts/__mocks__/mock_series_promises_response.json'; +import { of } from 'rxjs'; +import { cloneDeep } from 'lodash'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; +import type { ExplorerService } from '../explorer/explorer_dashboard_service'; +import type { MlApiServices } from './ml_api_service'; +import type { MlResultsService } from './results_service'; +import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; +import { timefilterMock } from '../contexts/kibana/__mocks__/use_timefilter'; +import { mlApiServicesMock } from './__mocks__/ml_api_services'; + +// Some notes on the tests and mocks: +// +// 'call anomalyChangeListener with actual series config' +// This test uses the standard mocks and uses the data as is provided via the mock files. +// The mocked services check for values in the data (e.g. 'mock-job-id', 'farequote-2017') +// and return the mock data from the files. +// +// 'filtering should skip values of null' +// This is is used to verify that values of `null` get filtered out but `0` is kept. +// The test clones mock data from files and adjusts job_id and indices to trigger +// suitable responses from the mocked services. The mocked services check against the +// provided alternative values and return specific modified mock responses for the test case. + +const mockJobConfigClone = cloneDeep(mockJobConfig); + +// adjust mock data to tests against null/0 values +const mockMetricClone = cloneDeep(mockSeriesPromisesResponse[0][0]); +// @ts-ignore +mockMetricClone.results['1486712700000'] = null; +// @ts-ignore +mockMetricClone.results['1486713600000'] = 0; + +export const mlResultsServiceMock = { + getMetricData: jest.fn((indices) => { + // this is for 'call anomalyChangeListener with actual series config' + if (indices[0] === 'farequote-2017') { + return of(mockSeriesPromisesResponse[0][0]); + } + // this is for 'filtering should skip values of null' + return of(mockMetricClone); + }), + getRecordsForCriteria: jest.fn(() => { + return of(mockSeriesPromisesResponse[0][1]); + }), + getScheduledEventsByBucket: jest.fn(() => of(mockSeriesPromisesResponse[0][2])), + getEventDistributionData: jest.fn((indices) => { + // this is for 'call anomalyChangeListener with actual series config' + if (indices[0] === 'farequote-2017') { + return Promise.resolve([]); + } + // this is for 'filtering should skip values of null' and + // resolves with a dummy object to trigger the processing + // of the event distribution chartdata filtering + return Promise.resolve([ + { + entity: 'mock', + }, + ]); + }), +}; + +const assertAnomalyDataResult = (anomalyData: ExplorerChartsData) => { + expect(anomalyData.chartsPerRow).toBe(1); + expect(Array.isArray(anomalyData.seriesToPlot)).toBe(true); + expect(anomalyData.seriesToPlot.length).toBe(1); + expect(anomalyData.errorMessages).toMatchObject({}); + expect(anomalyData.tooManyBuckets).toBe(false); + expect(anomalyData.timeFieldName).toBe('timestamp'); +}; +describe('AnomalyExplorerChartsService', () => { + const jobId = 'mock-job-id'; + const combinedJobRecords = { + [jobId]: mockJobConfigClone, + }; + const anomalyExplorerService = new AnomalyExplorerChartsService( + timefilterMock, + (mlApiServicesMock as unknown) as MlApiServices, + (mlResultsServiceMock as unknown) as MlResultsService + ); + const explorerService = { + setCharts: jest.fn(), + }; + + const timeRange = { + earliestMs: 1486656000000, + latestMs: 1486670399999, + }; + + beforeEach(() => { + mlApiServicesMock.jobs.jobForCloning.mockImplementation(() => + Promise.resolve({ job: mockJobConfigClone, datafeed: mockJobConfigClone.datafeed_config }) + ); + }); + + afterEach(() => { + explorerService.setCharts.mockClear(); + }); + + test('should return anomaly data without explorer service', async () => { + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + assertAnomalyDataResult(anomalyData); + }); + + test('should set anomaly data with explorer service side effects', async () => { + await anomalyExplorerService.getAnomalyData( + (explorerService as unknown) as ExplorerService, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + ); + + expect(explorerService.setCharts.mock.calls.length).toBe(2); + assertAnomalyDataResult(explorerService.setCharts.mock.calls[0][0]); + assertAnomalyDataResult(explorerService.setCharts.mock.calls[1][0]); + }); + + test('call anomalyChangeListener with empty series config', async () => { + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + // @ts-ignore + (combinedJobRecords as unknown) as Record, + 1000, + [], + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + expect(anomalyData).toStrictEqual({ + ...getDefaultChartsData(), + chartsPerRow: 2, + }); + }); + + test('field value with trailing dot should not throw an error', async () => { + const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords); + mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; + + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecordsClone, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + expect(anomalyData).toBeDefined(); + expect(anomalyData!.chartsPerRow).toBe(2); + expect(Array.isArray(anomalyData!.seriesToPlot)).toBe(true); + expect(anomalyData!.seriesToPlot.length).toBe(2); + }); +}); diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts new file mode 100644 index 0000000000000..59b6860cb65b7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -0,0 +1,1056 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { each, find, get, map, reduce, sortBy } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { RecordForInfluencer } from './results_service/results_service'; +import { + isMappableJob, + isModelPlotChartableForDetector, + isModelPlotEnabled, + isSourceDataChartableForDetector, + mlFunctionToESAggregation, +} from '../../../common/util/job_utils'; +import { EntityField, getEntityFieldList } from '../../../common/util/anomaly_utils'; +import { CombinedJob, Datafeed, JobId } from '../../../common/types/anomaly_detection_jobs'; +import { MlApiServices } from './ml_api_service'; +import { SWIM_LANE_LABEL_WIDTH } from '../explorer/swimlane_container'; +import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { _DOC_COUNT, DOC_COUNT } from '../../../common/constants/field_types'; +import { getChartType, chartLimits } from '../util/chart_utils'; +import { CriteriaField, MlResultsService } from './results_service'; +import { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public'; +import { CHART_TYPE, ChartType } from '../explorer/explorer_constants'; +import type { ChartRecord } from '../explorer/explorer_utils'; +import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx'; +import { isPopulatedObject } from '../../../common/util/object_utils'; +import type { ExplorerService } from '../explorer/explorer_dashboard_service'; +import { AnomalyRecordDoc } from '../../../common/types/anomalies'; +import { + ExplorerChartsData, + getDefaultChartsData, +} from '../explorer/explorer_charts/explorer_charts_container_service'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { isDefined } from '../../../common/types/guards'; +const CHART_MAX_POINTS = 500; +const ANOMALIES_MAX_RESULTS = 500; +const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. +const ML_TIME_FIELD_NAME = 'timestamp'; +const USE_OVERALL_CHART_LIMITS = false; +const MAX_CHARTS_PER_ROW = 4; + +interface ChartPoint { + date: number; + anomalyScore?: number; + actual?: number[]; + multiBucketImpact?: number; + typical?: number[]; + value?: number | null; + entity?: string; + byFieldName?: string; + numberOfCauses?: number; + scheduledEvents?: any[]; +} +interface MetricData { + results: Record; + success: boolean; +} +interface SeriesConfig { + jobId: JobId; + detectorIndex: number; + metricFunction: ML_JOB_AGGREGATION.LAT_LONG | ES_AGGREGATION | null; + timeField: string; + interval: string; + datafeedConfig: Datafeed; + summaryCountFieldName?: string; + metricFieldName?: string; +} + +interface InfoTooltip { + jobId: JobId; + aggregationInterval?: string; + chartFunction: string; + entityFields: EntityField[]; +} +export interface SeriesConfigWithMetadata extends SeriesConfig { + functionDescription?: string; + bucketSpanSeconds: number; + detectorLabel?: string; + fieldName: string; + entityFields: EntityField[]; + infoTooltip?: InfoTooltip; + loading?: boolean; + chartData?: ChartPoint[] | null; + mapData?: Array; +} + +export const isSeriesConfigWithMetadata = (arg: unknown): arg is SeriesConfigWithMetadata => { + return ( + isPopulatedObject(arg) && + {}.hasOwnProperty.call(arg, 'bucketSpanSeconds') && + {}.hasOwnProperty.call(arg, 'detectorLabel') + ); +}; + +interface ChartRange { + min: number; + max: number; +} + +export const DEFAULT_MAX_SERIES_TO_PLOT = 6; + +/** + * Service for retrieving anomaly explorer charts data. + */ +export class AnomalyExplorerChartsService { + private _customTimeRange: TimeRange | undefined; + + constructor( + private timeFilter: TimefilterContract, + private mlApiServices: MlApiServices, + private mlResultsService: MlResultsService + ) { + this.timeFilter.enableTimeRangeSelector(); + } + + public setTimeRange(timeRange: TimeRange) { + this._customTimeRange = timeRange; + } + + public getTimeBounds(): TimeRangeBounds { + return this._customTimeRange !== undefined + ? this.timeFilter.calculateBounds(this._customTimeRange) + : this.timeFilter.getBounds(); + } + + public calculateChartRange( + seriesConfigs: SeriesConfigWithMetadata[], + selectedEarliestMs: number, + selectedLatestMs: number, + chartWidth: number, + recordsToPlot: ChartRecord[], + timeFieldName: string, + timeFilter: TimefilterContract + ) { + let tooManyBuckets = false; + // Calculate the time range for the charts. + // Fit in as many points in the available container width plotted at the job bucket span. + // Look for the chart with the shortest bucket span as this determines + // the length of the time range that can be plotted. + const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2); + const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; + const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; + + const pointsToPlotFullSelection = Math.ceil( + (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs + ); + + // Optimally space points 5px apart. + const optimumPointSpacing = 5; + const optimumNumPoints = chartWidth / optimumPointSpacing; + + // Increase actual number of points if we can't plot the selected range + // at optimal point spacing. + const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); + const halfPoints = Math.ceil(plotPoints / 2); + const bounds = timeFilter.getActiveBounds(); + const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined; + let chartRange: ChartRange = { + min: boundsMin + ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin) + : midpointMs - halfPoints * minBucketSpanMs, + max: bounds?.max + ? Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()) + : midpointMs + halfPoints * minBucketSpanMs, + }; + + if (plotPoints > CHART_MAX_POINTS) { + // For each series being plotted, display the record with the highest score if possible. + const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS; + let minMs = recordsToPlot[0][timeFieldName]; + let maxMs = recordsToPlot[0][timeFieldName]; + + each(recordsToPlot, (record) => { + const diffMs = maxMs - minMs; + if (diffMs < maxTimeSpan) { + const recordTime = record[timeFieldName]; + if (recordTime < minMs) { + if (maxMs - recordTime <= maxTimeSpan) { + minMs = recordTime; + } + } + + if (recordTime > maxMs) { + if (recordTime - minMs <= maxTimeSpan) { + maxMs = recordTime; + } + } + } + }); + + if (maxMs - minMs < maxTimeSpan) { + // Expand out before and after the span with the highest scoring anomalies, + // covering as much as the requested time span as possible. + // Work out if the high scoring region is nearer the start or end of the selected time span. + const diff = maxTimeSpan - (maxMs - minMs); + if (minMs - 0.5 * diff <= selectedEarliestMs) { + minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff); + maxMs = minMs + maxTimeSpan; + } else { + maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff); + minMs = maxMs - maxTimeSpan; + } + } + + chartRange = { min: minMs, max: maxMs }; + } + + // Elasticsearch aggregation returns points at start of bucket, + // so align the min to the length of the longest bucket. + chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; + if (boundsMin !== undefined && chartRange.min < boundsMin) { + chartRange.min = chartRange.min + maxBucketSpanMs; + } + + if ( + (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && + chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs + ) { + tooManyBuckets = true; + } + + return { + chartRange, + tooManyBuckets, + }; + } + + public buildConfigFromDetector(job: CombinedJob, detectorIndex: number) { + const analysisConfig = job.analysis_config; + const detector = analysisConfig.detectors[detectorIndex]; + + const config: SeriesConfig = { + jobId: job.job_id, + detectorIndex, + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), + timeField: job.data_description.time_field, + interval: job.analysis_config.bucket_span, + datafeedConfig: job.datafeed_config, + summaryCountFieldName: job.analysis_config.summary_count_field_name, + metricFieldName: undefined, + }; + + if (detector.field_name !== undefined) { + config.metricFieldName = detector.field_name; + } + + // Extra checks if the job config uses a summary count field. + const summaryCountFieldName = analysisConfig.summary_count_field_name; + if ( + config.metricFunction === ES_AGGREGATION.COUNT && + summaryCountFieldName !== undefined && + summaryCountFieldName !== DOC_COUNT && + summaryCountFieldName !== _DOC_COUNT + ) { + // Check for a detector looking at cardinality (distinct count) using an aggregation. + // The cardinality field will be in: + // aggregations//aggregations//cardinality/field + // or aggs//aggs//cardinality/field + let cardinalityField; + const topAgg = get(job.datafeed_config, 'aggregations') || get(job.datafeed_config, 'aggs'); + if (topAgg !== undefined && Object.values(topAgg).length > 0) { + cardinalityField = + get(Object.values(topAgg)[0], [ + 'aggregations', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]) || + get(Object.values(topAgg)[0], [ + 'aggs', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]); + } + if ( + (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.COUNT || + detector.function === ML_JOB_AGGREGATION.HIGH_COUNT || + detector.function === ML_JOB_AGGREGATION.LOW_COUNT) && + cardinalityField !== undefined + ) { + config.metricFunction = ES_AGGREGATION.CARDINALITY; + config.metricFieldName = undefined; + } else { + // For count detectors using summary_count_field, plot sum(summary_count_field_name) + config.metricFunction = ES_AGGREGATION.SUM; + config.metricFieldName = summaryCountFieldName; + } + } + + return config; + } + + public buildConfig(record: ChartRecord, job: CombinedJob): SeriesConfigWithMetadata { + const detectorIndex = record.detector_index; + const config: Omit< + SeriesConfigWithMetadata, + 'bucketSpanSeconds' | 'detectorLabel' | 'fieldName' | 'entityFields' | 'infoTooltip' + > = { + ...this.buildConfigFromDetector(job, detectorIndex), + }; + + const fullSeriesConfig: SeriesConfigWithMetadata = { + bucketSpanSeconds: 0, + entityFields: [], + fieldName: '', + ...config, + }; + // Add extra properties used by the explorer dashboard charts. + fullSeriesConfig.functionDescription = record.function_description; + + const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span); + if (parsedBucketSpan !== null) { + fullSeriesConfig.bucketSpanSeconds = parsedBucketSpan.asSeconds(); + } + + fullSeriesConfig.detectorLabel = record.function; + const jobDetectors = job.analysis_config.detectors; + if (jobDetectors) { + fullSeriesConfig.detectorLabel = jobDetectors[detectorIndex].detector_description; + } else { + if (record.field_name !== undefined) { + fullSeriesConfig.detectorLabel += ` ${fullSeriesConfig.fieldName}`; + } + } + + if (record.field_name !== undefined) { + fullSeriesConfig.fieldName = record.field_name; + fullSeriesConfig.metricFieldName = record.field_name; + } + + // Add the 'entity_fields' i.e. the partition, by, over fields which + // define the metric series to be plotted. + fullSeriesConfig.entityFields = getEntityFieldList(record); + + if (record.function === ML_JOB_AGGREGATION.METRIC) { + fullSeriesConfig.metricFunction = mlFunctionToESAggregation(record.function_description); + } + + // Build the tooltip data for the chart info icon, showing further details on what is being plotted. + let functionLabel = `${config.metricFunction}`; + if ( + fullSeriesConfig.metricFieldName !== undefined && + fullSeriesConfig.metricFieldName !== null + ) { + functionLabel += ` ${fullSeriesConfig.metricFieldName}`; + } + + fullSeriesConfig.infoTooltip = { + jobId: record.job_id, + aggregationInterval: fullSeriesConfig.interval, + chartFunction: functionLabel, + entityFields: fullSeriesConfig.entityFields.map((f) => ({ + fieldName: f.fieldName, + fieldValue: f.fieldValue, + })), + }; + + return fullSeriesConfig; + } + public async getCombinedJobs(jobIds: string[]): Promise { + const combinedResults = await Promise.all( + // Getting only necessary job config and datafeed config without the stats + jobIds.map((jobId) => this.mlApiServices.jobs.jobForCloning(jobId)) + ); + const combinedJobs = combinedResults + .filter(isDefined) + .filter((r) => r.job !== undefined && r.datafeed !== undefined) + .map(({ job, datafeed }) => ({ ...job, datafeed_config: datafeed } as CombinedJob)); + return combinedJobs; + } + + public async getAnomalyData( + explorerService: ExplorerService | undefined, + combinedJobRecords: Record, + chartsContainerWidth: number, + anomalyRecords: ChartRecord[] | undefined, + selectedEarliestMs: number, + selectedLatestMs: number, + timefilter: TimefilterContract, + severity = 0, + maxSeries = DEFAULT_MAX_SERIES_TO_PLOT + ): Promise { + const data = getDefaultChartsData(); + + const containerWith = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH; + if (anomalyRecords === undefined) return; + const filteredRecords = anomalyRecords.filter((record) => { + return Number(record.record_score) >= severity; + }); + const { records: allSeriesRecords, errors: errorMessages } = this.processRecordsForDisplay( + combinedJobRecords, + filteredRecords + ); + + if (!Array.isArray(allSeriesRecords)) return; + // Calculate the number of charts per row, depending on the width available, to a max of 4. + let chartsPerRow = Math.min(Math.max(Math.floor(containerWith / 550), 1), MAX_CHARTS_PER_ROW); + + // Expand the chart to full size if there's only one viewable chart + if (allSeriesRecords.length === 1 || maxSeries === 1) { + chartsPerRow = 1; + } + + // Expand the charts to not have blank space in the row if necessary + if (maxSeries < chartsPerRow) { + chartsPerRow = maxSeries; + } + + data.chartsPerRow = chartsPerRow; + + // Build the data configs of the anomalies to be displayed. + // TODO - implement paging? + // For now just take first 6 (or 8 if 4 charts per row). + const maxSeriesToPlot = maxSeries ?? Math.max(chartsPerRow * 2, 6); + const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const hasGeoData = recordsToPlot.find( + (record) => (record.function_description || record.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map((record) => + this.buildConfig(record, combinedJobRecords[record.job_id]) + ); + const seriesConfigsNoGeoData = []; + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData: SeriesConfigWithMetadata[] = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if ( + config.detectorLabel !== undefined && + config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG) + ) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } + + // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. + data.tooManyBuckets = false; + const chartWidth = Math.floor(containerWith / chartsPerRow); + const { chartRange, tooManyBuckets } = this.calculateChartRange( + seriesConfigs as SeriesConfigWithMetadata[], + selectedEarliestMs, + selectedLatestMs, + chartWidth, + recordsToPlot, + data.timeFieldName, + timefilter + ); + data.tooManyBuckets = tooManyBuckets; + + if (errorMessages) { + data.errorMessages = errorMessages; + } + + if (explorerService) { + explorerService.setCharts({ ...data }); + } + if (seriesConfigs.length === 0) { + return data; + } + + // Query 1 - load the raw metric data. + function getMetricData( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ): Promise { + const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config; + + const job = combinedJobRecords[jobId]; + + // If the job uses aggregation or scripted fields, and if it's a config we don't support + // use model plot data if model plot is enabled + // else if source data can be plotted, use that, otherwise model plot will be available. + const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); + if (useSourceData === true) { + const datafeedQuery = get(config, 'datafeedConfig.query', null); + return mlResultsService + .getMetricData( + Array.isArray(config.datafeedConfig.indices) + ? config.datafeedConfig.indices[0] + : config.datafeedConfig.indices, + entityFields, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.summaryCountFieldName, + config.timeField, + range.min, + range.max, + bucketSpanSeconds * 1000, + config.datafeedConfig + ) + .toPromise(); + } else { + // Extract the partition, by, over fields on which to filter. + const criteriaFields: CriteriaField[] = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity = find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); + } + } + + if (detector.over_field_name !== undefined) { + const overEntity = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); + } + } + + if (detector.by_field_name !== undefined) { + const byEntity = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); + } + } + + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {} as Record, + }; + + return mlResultsService + .getModelPlotOutput( + jobId, + detectorIndex, + criteriaFields, + range.min, + range.max, + bucketSpanSeconds * 1000 + ) + .toPromise() + .then((resp) => { + // Return data in format required by the explorer charts. + const results = resp.results; + Object.keys(results).forEach((time) => { + obj.results[time] = results[time].actual; + }); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + } + } + + // Query 2 - load the anomalies. + // Criteria to return the records for this series are the detector_index plus + // the specific combination of 'entity' fields i.e. the partition / by / over fields. + function getRecordsForCriteria( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + let criteria: EntityField[] = []; + criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); + criteria = criteria.concat(config.entityFields); + return mlResultsService + .getRecordsForCriteria( + [config.jobId], + criteria, + 0, + range.min, + range.max, + ANOMALIES_MAX_RESULTS + ) + .toPromise(); + } + + // Query 3 - load any scheduled events for the job. + function getScheduledEvents( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + return mlResultsService + .getScheduledEventsByBucket( + [config.jobId], + range.min, + range.max, + config.bucketSpanSeconds * 1000, + 1, + MAX_SCHEDULED_EVENTS + ) + .toPromise(); + } + + // Query 4 - load context data distribution + function getEventDistribution( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + const chartType = getChartType(config); + + let splitField; + let filterField = null; + + // Define splitField and filterField based on chartType + if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { + splitField = config.entityFields.find((f) => f.fieldType === 'by'); + filterField = config.entityFields.find((f) => f.fieldType === 'partition'); + } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { + splitField = config.entityFields.find((f) => f.fieldType === 'over'); + filterField = config.entityFields.find((f) => f.fieldType === 'partition'); + } + + const datafeedQuery = get(config, 'datafeedConfig.query', null); + return mlResultsService.getEventDistributionData( + Array.isArray(config.datafeedConfig.indices) + ? config.datafeedConfig.indices[0] + : config.datafeedConfig.indices, + splitField, + filterField, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.bucketSpanSeconds * 1000 + ); + } + + // first load and wait for required data, + // only after that trigger data processing and page render. + // TODO - if query returns no results e.g. source data has been deleted, + // display a message saying 'No data between earliest/latest'. + const seriesPromises: Array< + Promise<[MetricData, RecordsForCriteria, ScheduledEventsByBucket, any]> + > = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesConfigsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesConfigsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(this.mlResultsService, seriesConfig, chartRange), + getRecordsForCriteria(this.mlResultsService, seriesConfig, chartRange), + getScheduledEvents(this.mlResultsService, seriesConfig, chartRange), + getEventDistribution(this.mlResultsService, seriesConfig, chartRange), + ]) + ); + }); + function processChartData( + response: [MetricData, RecordsForCriteria, ScheduledEventsByBucket, any], + seriesIndex: number + ) { + const metricData = response[0].results; + const records = response[1].records; + const jobId = seriesConfigsForPromises[seriesIndex].jobId; + const scheduledEvents = response[2].events[jobId]; + const eventDistribution = response[3]; + const chartType = getChartType(seriesConfigsForPromises[seriesIndex]); + + // Sort records in ascending time order matching up with chart data + records.sort((recordA, recordB) => { + return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; + }); + + // Return dataset in format used by the chart. + // i.e. array of Objects with keys date (timestamp), value, + // plus anomalyScore for points with anomaly markers. + let chartData: ChartPoint[] = []; + if (metricData !== undefined) { + if (eventDistribution.length > 0 && records.length > 0) { + const filterField = records[0].by_field_value || records[0].over_field_value; + chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField); + map(metricData, (value, time) => { + // The filtering for rare/event_distribution charts needs to be handled + // differently because of how the source data is structured. + // For rare chart values we are only interested wether a value is either `0` or not, + // `0` acts like a flag in the chart whether to display the dot/marker. + // All other charts (single metric, population) are metric based and with + // those a value of `null` acts as the flag to hide a data point. + if ( + (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || + (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) + ) { + chartData.push({ + date: +time, + value, + entity: filterField, + }); + } + }); + } else { + chartData = map(metricData, (value, time) => ({ + date: +time, + value, + })); + } + } + + // Iterate through the anomaly records, adding anomalyScore properties + // to the chartData entries for anomalous buckets. + const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); + each(records, (record) => { + // Look for a chart point with the same time as the record. + // If none found, insert a point for anomalies due to a gap in the data. + const recordTime = record[ML_TIME_FIELD_NAME]; + let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); + if (chartPoint === undefined) { + chartPoint = { date: recordTime, value: null }; + chartData.push(chartPoint); + } + if (chartPoint !== undefined) { + chartPoint.anomalyScore = record.record_score; + + if (record.actual !== undefined) { + chartPoint.actual = record.actual; + chartPoint.typical = record.typical; + } else { + const causes = get(record, 'causes', []); + if (causes.length > 0) { + chartPoint.byFieldName = record.by_field_name; + chartPoint.numberOfCauses = causes.length; + if (causes.length === 1) { + // If only a single cause, copy actual and typical values to the top level. + const cause = record.causes[0]; + chartPoint.actual = cause.actual; + chartPoint.typical = cause.typical; + } + } + } + + if (record.multi_bucket_impact !== undefined) { + chartPoint.multiBucketImpact = record.multi_bucket_impact; + } + } + }); + + // Add a scheduledEvents property to any points in the chart data set + // which correspond to times of scheduled events for the job. + if (scheduledEvents !== undefined) { + each(scheduledEvents, (events, time) => { + const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); + if (chartPoint !== undefined) { + // Note if the scheduled event coincides with an absence of the underlying metric data, + // we don't worry about plotting the event. + chartPoint.scheduledEvents = events; + } + }); + } + + return chartData; + } + + function getChartDataForPointSearch( + chartData: ChartPoint[], + record: AnomalyRecordDoc, + chartType: ChartType + ) { + if ( + chartType === CHART_TYPE.EVENT_DISTRIBUTION || + chartType === CHART_TYPE.POPULATION_DISTRIBUTION + ) { + return chartData.filter((d) => { + return d.entity === (record && (record.by_field_value || record.over_field_value)); + }); + } + + return chartData; + } + + function findChartPointForTime(chartData: ChartPoint[], time: number) { + return chartData.find((point) => point.date === time); + } + + return Promise.all(seriesPromises) + .then((response) => { + // calculate an overall min/max for all series + const processedData = response.map(processChartData); + const allDataPoints = reduce( + processedData, + (datapoints, series) => { + each(series, (d) => datapoints.push(d)); + return datapoints; + }, + [] as ChartPoint[] + ); + const overallChartLimits = chartLimits(allDataPoints); + + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesConfigsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } + if (explorerService) { + explorerService.setCharts({ ...data }); + } + return Promise.resolve(data); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + } + + public processRecordsForDisplay( + jobRecords: Record, + anomalyRecords: RecordForInfluencer[] + ): { records: ChartRecord[]; errors: Record> | undefined } { + // Aggregate the anomaly data by detector, and entity (by/over/partition). + if (anomalyRecords.length === 0) { + return { records: [], errors: undefined }; + } + // Aggregate by job, detector, and analysis fields (partition, by, over). + const aggregatedData: Record = {}; + + const jobsErrorMessage: Record = {}; + each(anomalyRecords, (record) => { + // Check if we can plot a chart for this record, depending on whether the source data + // is chartable, and if model plot is enabled for the job. + + const job = jobRecords[record.job_id]; + + // if we already know this job has datafeed aggregations we cannot support + // no need to do more checks + if (jobsErrorMessage[record.job_id] !== undefined) { + return; + } + + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + + if (isChartable === false) { + if (isModelPlotChartableForDetector(job, record.detector_index)) { + // Check if model plot is enabled for this job. + // Need to check the entity fields for the record in case the model plot config has a terms list. + const entityFields = getEntityFieldList(record); + if (isModelPlotEnabled(job, record.detector_index, entityFields)) { + isChartable = true; + } else { + isChartable = false; + jobsErrorMessage[record.job_id] = i18n.translate( + 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage', + { + defaultMessage: + 'source data is not viewable for this detector and model plot is disabled', + } + ); + } + } else { + jobsErrorMessage[record.job_id] = i18n.translate( + 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage', + { + defaultMessage: 'both source data and model plot are not chartable for this detector', + } + ); + } + } + + if (isChartable === false) { + return; + } + const jobId = record.job_id; + if (aggregatedData[jobId] === undefined) { + aggregatedData[jobId] = {}; + } + const detectorsForJob = aggregatedData[jobId]; + + const detectorIndex = record.detector_index; + if (detectorsForJob[detectorIndex] === undefined) { + detectorsForJob[detectorIndex] = {}; + } + + // TODO - work out how best to display results from detectors with just an over field. + const firstFieldName = + record.partition_field_name || record.by_field_name || record.over_field_name; + const firstFieldValue = + record.partition_field_value || record.by_field_value || record.over_field_value; + if (firstFieldName !== undefined && firstFieldValue !== undefined) { + const groupsForDetector = detectorsForJob[detectorIndex]; + + if (groupsForDetector[firstFieldName] === undefined) { + groupsForDetector[firstFieldName] = {}; + } + const valuesForGroup: Record = groupsForDetector[firstFieldName]; + if (valuesForGroup[firstFieldValue] === undefined) { + valuesForGroup[firstFieldValue] = {}; + } + + const dataForGroupValue = valuesForGroup[firstFieldValue]; + + let isSecondSplit = false; + if (record.partition_field_name !== undefined) { + const splitFieldName = record.over_field_name || record.by_field_name; + if (splitFieldName !== undefined) { + isSecondSplit = true; + } + } + + if (isSecondSplit === false) { + if (dataForGroupValue.maxScoreRecord === undefined) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForGroupValue.maxScore) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } + } + } else { + // Aggregate another level for the over or by field. + const secondFieldName = record.over_field_name || record.by_field_name; + const secondFieldValue = record.over_field_value || record.by_field_value; + + if (secondFieldName !== undefined && secondFieldValue !== undefined) { + if (dataForGroupValue[secondFieldName] === undefined) { + dataForGroupValue[secondFieldName] = {}; + } + + const splitsForGroup = dataForGroupValue[secondFieldName]; + if (splitsForGroup[secondFieldValue] === undefined) { + splitsForGroup[secondFieldValue] = {}; + } + + const dataForSplitValue = splitsForGroup[secondFieldValue]; + if (dataForSplitValue.maxScoreRecord === undefined) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForSplitValue.maxScore) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } + } + } + } + } else { + // Detector with no partition or by field. + const dataForDetector = detectorsForJob[detectorIndex]; + if (dataForDetector.maxScoreRecord === undefined) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; + } else { + if (record.record_score > dataForDetector.maxScore) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; + } + } + } + }); + + // Group job id by error message instead of by job: + const errorMessages: Record> | undefined = {}; + Object.keys(jobsErrorMessage).forEach((jobId) => { + const msg = jobsErrorMessage[jobId]; + if (errorMessages[msg] === undefined) { + errorMessages[msg] = new Set([jobId]); + } else { + errorMessages[msg].add(jobId); + } + }); + let recordsForSeries: ChartRecord[] = []; + // Convert to an array of the records with the highest record_score per unique series. + each(aggregatedData, (detectorsForJob) => { + each(detectorsForJob, (groupsForDetector) => { + if (groupsForDetector.errorMessage !== undefined) { + recordsForSeries.push(groupsForDetector.errorMessage); + } else { + if (groupsForDetector.maxScoreRecord !== undefined) { + // Detector with no partition / by field. + recordsForSeries.push(groupsForDetector.maxScoreRecord); + } else { + each(groupsForDetector, (valuesForGroup) => { + each(valuesForGroup, (dataForGroupValue) => { + if (dataForGroupValue.maxScoreRecord !== undefined) { + recordsForSeries.push(dataForGroupValue.maxScoreRecord); + } else { + // Second level of aggregation for partition and by/over. + each(dataForGroupValue, (splitsForGroup) => { + each(splitsForGroup, (dataForSplitValue) => { + recordsForSeries.push(dataForSplitValue.maxScoreRecord); + }); + }); + } + }); + }); + } + } + }); + }); + recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse(); + + return { records: recordsForSeries, errors: errorMessages }; + } +} diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index e6d0d93cade1f..4acb7fca09d0d 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -25,6 +25,7 @@ import { import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; import { BucketSpanEstimatorData } from '../../../../common/types/job_service'; +import { RuntimeMappings } from '../../../../common/types/fields'; import { Job, JobStats, @@ -690,14 +691,16 @@ export function mlApiServicesProvider(httpService: HttpService) { index, timeFieldName, query, + runtimeMappings, indicesOptions, }: { index: string; timeFieldName?: string; query: any; + runtimeMappings?: RuntimeMappings; indicesOptions?: IndicesOptions; }) { - const body = JSON.stringify({ index, timeFieldName, query, indicesOptions }); + const body = JSON.stringify({ index, timeFieldName, query, runtimeMappings, indicesOptions }); return httpService.http({ path: `${basePath()}/fields_service/time_field_range`, diff --git a/x-pack/plugins/ml/public/application/services/ml_results_service.ts b/x-pack/plugins/ml/public/application/services/ml_results_service.ts new file mode 100644 index 0000000000000..aafeb23f11f65 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/ml_results_service.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const createMlResultsServiceMock = () => ({ + getMetricData: jest.fn(), + getModelPlotOutput: jest.fn(), + getRecordsForCriteria: jest.fn(), + getScheduledEventsByBucket: jest.fn(), + fetchPartitionFieldsValues: jest.fn(), + getEventDistributionData: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index a8ae42658f368..e07d49ca23d3b 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -69,8 +69,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { index: string, entityFields: any[], query: object | undefined, - metricFunction: string, // ES aggregation name - metricFieldName: string, + metricFunction: string | null, // ES aggregation name + metricFieldName: string | undefined, summaryCountFieldName: string | undefined, timeFieldName: string, earliestMs: number, @@ -243,7 +243,7 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { getModelPlotOutput( jobId: string, detectorIndex: number, - criteriaFields: any[], + criteriaFields: CriteriaField[], earliestMs: number, latestMs: number, intervalMs: number, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index f9a2c1389c828..bb0cdc89904f8 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -7,7 +7,11 @@ import { IndicesOptions } from '../../../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../ml_api_service'; +import type { AnomalyRecordDoc } from '../../../../common/types/anomalies'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import { EntityField } from '../../../../common/util/anomaly_utils'; +type RecordForInfluencer = AnomalyRecordDoc; export function resultsServiceProvider( mlApiServices: MlApiServices ): { @@ -27,7 +31,7 @@ export function resultsServiceProvider( perPage?: number, fromPage?: number, influencers?: any[], - influencersFilterQuery?: any + influencersFilterQuery?: InfluencersFilterQuery ): Promise; getTopInfluencerValues(): Promise; getOverallBucketScores( @@ -47,10 +51,10 @@ export function resultsServiceProvider( maxResults: number, perPage: number, fromPage: number, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ): Promise; getRecordInfluencers(): Promise; - getRecordsForInfluencer(): Promise; + getRecordsForInfluencer(): Promise; getRecordsForDetector(): Promise; getRecords(): Promise; getEventRateData( @@ -64,11 +68,11 @@ export function resultsServiceProvider( ): Promise; getEventDistributionData( index: string, - splitField: string, - filterField: string, + splitField: EntityField | undefined | null, + filterField: EntityField | undefined | null, query: any, - metricFunction: string, // ES aggregation name - metricFieldName: string, + metricFunction: string | undefined | null, // ES aggregation name + metricFieldName: string | undefined, timeFieldName: string, earliestMs: number, latestMs: number, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 502692da39c96..fa0bcd6ea987d 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -1232,7 +1232,11 @@ export function resultsServiceProvider(mlApiServices) { }, }; - if (metricFieldName !== undefined && metricFieldName !== '') { + if ( + metricFieldName !== undefined && + metricFieldName !== '' && + typeof metricFunction === 'string' + ) { body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; const metricAgg = { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index 9c4e56e292ed0..658926a5a96a9 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -6,8 +6,7 @@ */ import React from 'react'; - -import { TimeRangeBounds } from '../explorer/explorer_utils'; +import { TimeRangeBounds } from '../util/time_buckets'; interface Props { appStateHandler: (action: string, payload: any) => void; diff --git a/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts b/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts new file mode 100644 index 0000000000000..70e756933b86e --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const timeBucketsMock = { + setBarTarget: jest.fn(), + setMaxBars: jest.fn(), + setInterval: jest.fn(), + setBounds: jest.fn(), + getBounds: jest.fn(), + getInterval: jest.fn(), + getScaledDateFormat: jest.fn(), +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts index 1c94cc6e82f8b..ee85525ec00f4 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts +++ b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts @@ -5,4 +5,13 @@ * 2.0. */ +import type { ChartType } from '../explorer/explorer_constants'; + export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number; +export declare function getChartType(config: any): ChartType; +export declare function chartLimits( + data: any[] +): { + min: number; + max: number; +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 5ffe2fe86ec32..9b5cab41f24e2 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -9,7 +9,6 @@ import d3 from 'd3'; import { calculateTextWidth } from './string_utils'; import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact'; import moment from 'moment'; -import { getTimefilter } from './dependency_cache'; import { CHART_TYPE } from '../explorer/explorer_constants'; import { ML_PAGES } from '../../../common/constants/ml_url_generator'; @@ -220,10 +219,9 @@ export function getChartType(config) { return chartType; } -export async function getExploreSeriesLink(mlUrlGenerator, series) { +export async function getExploreSeriesLink(mlUrlGenerator, series, timefilter) { // Open the Single Metric dashboard over the same overall bounds and // zoomed in to the same time as the current chart. - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z const to = bounds.max.toISOString(); diff --git a/x-pack/plugins/ml/public/application/util/recently_accessed.ts b/x-pack/plugins/ml/public/application/util/recently_accessed.ts index 0967d4a0587e3..88f78946bf7b4 100644 --- a/x-pack/plugins/ml/public/application/util/recently_accessed.ts +++ b/x-pack/plugins/ml/public/application/util/recently_accessed.ts @@ -9,9 +9,15 @@ import { i18n } from '@kbn/i18n'; +import type { ChromeRecentlyAccessed } from 'kibana/public'; import { getRecentlyAccessed } from './dependency_cache'; -export function addItemToRecentlyAccessed(page: string, itemId: string, url: string) { +export function addItemToRecentlyAccessed( + page: string, + itemId: string, + url: string, + recentlyAccessedService?: ChromeRecentlyAccessed +) { let pageLabel = ''; let id = `ml-job-${itemId}`; @@ -39,6 +45,6 @@ export function addItemToRecentlyAccessed(page: string, itemId: string, url: str } url = url.startsWith('/') ? `/app/ml${url}` : `/app/ml/${page}/${url}`; - const recentlyAccessed = getRecentlyAccessed(); + const recentlyAccessed = recentlyAccessedService ?? getRecentlyAccessed(); recentlyAccessed.add(url, `ML - ${itemId} - ${pageLabel}`, id); } diff --git a/x-pack/plugins/ml/public/application/util/string_utils.ts b/x-pack/plugins/ml/public/application/util/string_utils.ts index 9cd22d1d6ce76..b981bbb8fe1a6 100644 --- a/x-pack/plugins/ml/public/application/util/string_utils.ts +++ b/x-pack/plugins/ml/public/application/util/string_utils.ts @@ -74,7 +74,7 @@ export function detectorToString(dtr: Detector): string { txt += PARTITION_FIELD_OPTION + quoteField(dtr.partition_field_name); } - if (dtr.exclude_frequent !== undefined && dtr.exclude_frequent !== '') { + if (dtr.exclude_frequent !== undefined) { txt += EXCLUDE_FREQUENT_OPTION + dtr.exclude_frequent; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap new file mode 100644 index 0000000000000..375b041c4db73 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmbeddableAnomalyChartsContainer should render explorer charts with a valid embeddable input 1`] = ` +Object { + "chartsData": Object { + "chartsPerRow": 2, + "errorMessages": undefined, + "seriesToPlot": Array [], + "timeFieldName": "@timestamp", + "tooManyBuckets": false, + }, + "id": "test-explorer-charts-embeddable", + "mlUrlGenerator": undefined, + "onSelectEntity": [Function], + "setSeverity": [Function], + "severity": Object { + "color": "#fe5050", + "display": "critical", + "val": 75, + }, + "showCharts": true, + "timeBuckets": TimeBuckets { + "_timeBucketsConfig": Object { + "dateFormat": undefined, + "dateFormat:scaled": undefined, + "histogram:barTarget": undefined, + "histogram:maxBars": undefined, + }, + "barTarget": undefined, + "maxBars": undefined, + }, + "timefilter": Object { + "calculateBounds": [MockFunction], + "createFilter": [MockFunction], + "disableAutoRefreshSelector": [MockFunction], + "disableTimeRangeSelector": [MockFunction], + "enableAutoRefreshSelector": [MockFunction], + "enableTimeRangeSelector": [MockFunction], + "getAbsoluteTime": [MockFunction], + "getActiveBounds": [MockFunction], + "getAutoRefreshFetch$": [MockFunction], + "getBounds": [MockFunction], + "getEnabledUpdated$": [MockFunction], + "getFetch$": [MockFunction], + "getRefreshInterval": [MockFunction], + "getRefreshIntervalDefaults": [MockFunction], + "getRefreshIntervalUpdate$": [MockFunction], + "getTime": [MockFunction], + "getTimeDefaults": [MockFunction], + "getTimeUpdate$": [MockFunction], + "isAutoRefreshSelectorEnabled": [MockFunction], + "isTimeRangeSelectorEnabled": [MockFunction], + "isTimeTouched": [MockFunction], + "setRefreshInterval": [MockFunction], + "setTime": [MockFunction], + }, +} +`; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx new file mode 100644 index 0000000000000..298abd4dcc241 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { Subject } from 'rxjs'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { Embeddable, IContainer } from '../../../../../../src/plugins/embeddable/public'; +import { EmbeddableAnomalyChartsContainer } from './embeddable_anomaly_charts_container_lazy'; +import type { JobId } from '../../../common/types/anomaly_detection_jobs'; +import type { MlDependencies } from '../../application/app'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsServices, +} from '..'; +import type { IndexPattern } from '../../../../../../src/plugins/data/common/index_patterns'; +import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; +export const getDefaultExplorerChartsPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.anomalyChartsEmbeddable.title', { + defaultMessage: 'ML anomaly charts for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + +export type IAnomalyChartsEmbeddable = typeof AnomalyChartsEmbeddable; + +export class AnomalyChartsEmbeddable extends Embeddable< + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput +> { + private node?: HTMLElement; + private reload$ = new Subject(); + public readonly type: string = ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; + + constructor( + initialInput: AnomalyChartsEmbeddableInput, + public services: [CoreStart, MlDependencies, AnomalyChartsServices], + parent?: IContainer + ) { + super( + initialInput, + { + defaultTitle: initialInput.title, + }, + parent + ); + this.initializeOutput(initialInput); + } + + private async initializeOutput(initialInput: AnomalyChartsEmbeddableInput) { + const { anomalyExplorerService } = this.services[2]; + const { jobIds } = initialInput; + + try { + const jobs = await anomalyExplorerService.getCombinedJobs(jobIds); + const indexPatternsService = this.services[1].data.indexPatterns; + + // First get list of unique indices from the selected jobs + const indices = new Set(jobs.map((j) => j.datafeed_config.indices).flat()); + + // Then find the index patterns assuming the index pattern title matches the index name + const indexPatterns: Record = {}; + for (const indexName of indices) { + const response = await indexPatternsService.find(`"${indexName}"`); + + const indexPattern = response.find( + (obj) => obj.title.toLowerCase() === indexName.toLowerCase() + ); + if (indexPattern !== undefined) { + indexPatterns[indexPattern.id!] = indexPattern; + } + } + + this.updateOutput({ + ...this.getOutput(), + indexPatterns: Object.values(indexPatterns), + }); + } catch (e) { + // Unable to find and load index pattern but we can ignore the error + // as we only load it to support the filter & query bar + // the visualizations should still work correctly + + // eslint-disable-next-line no-console + console.error(`Unable to load index patterns for ${jobIds}`, e); + } + } + + public render(node: HTMLElement) { + super.render(node); + this.node = node; + + const I18nContext = this.services[0].i18n.Context; + + ReactDOM.render( + + + }> + + + + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() { + this.reload$.next(); + } + + public supportedTriggers() { + return []; + } +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.test.ts new file mode 100644 index 0000000000000..441ac145e1bd4 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnomalyChartsEmbeddableFactory } from './anomaly_charts_embeddable_factory'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { AnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import { AnomalyChartsEmbeddableInput } from '..'; + +jest.mock('./anomaly_charts_embeddable', () => ({ + AnomalyChartsEmbeddable: jest.fn(), +})); + +describe('AnomalyChartsEmbeddableFactory', () => { + test('should provide required services on create', async () => { + // arrange + const pluginStartDeps = { data: dataPluginMock.createStartContract() }; + + const getStartServices = coreMock.createSetup({ + pluginStartDeps, + }).getStartServices; + + const [coreStart, pluginsStart] = await getStartServices(); + + // act + const factory = new AnomalyChartsEmbeddableFactory(getStartServices); + + await factory.create({ + jobIds: ['test-job'], + maxSeriesToPlot: 4, + } as AnomalyChartsEmbeddableInput); + + // assert + const mockCalls = ((AnomalyChartsEmbeddable as unknown) as jest.Mock) + .mock.calls[0]; + const input = mockCalls[0]; + const createServices = mockCalls[1]; + + expect(input).toEqual({ + jobIds: ['test-job'], + maxSeriesToPlot: 4, + }); + expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart)); + expect(createServices[1]).toMatchObject(pluginsStart); + expect(Object.keys(createServices[2])).toEqual([ + 'anomalyDetectorService', + 'anomalyExplorerService', + 'mlResultsService', + ]); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts new file mode 100644 index 0000000000000..ac5ff2094e22b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import type { StartServicesAccessor } from 'kibana/public'; + +import type { + EmbeddableFactoryDefinition, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { HttpService } from '../../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../../plugin'; +import type { MlDependencies } from '../../application/app'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableServices, +} from '..'; +import { AnomalyExplorerChartsService } from '../../application/services/anomaly_explorer_charts_service'; + +export class AnomalyChartsEmbeddableFactory + implements EmbeddableFactoryDefinition { + public readonly type = ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; + + constructor( + private getStartServices: StartServicesAccessor + ) {} + + public async isEditable() { + return true; + } + + public getDisplayName() { + return i18n.translate('xpack.ml.components.mlAnomalyExplorerEmbeddable.displayName', { + defaultMessage: 'ML anomaly chart', + }); + } + + public async getExplicitInput(): Promise> { + const [coreStart] = await this.getServices(); + + try { + const { resolveEmbeddableAnomalyChartsUserInput } = await import( + './anomaly_charts_setup_flyout' + ); + return await resolveEmbeddableAnomalyChartsUserInput(coreStart); + } catch (e) { + return Promise.reject(); + } + } + + private async getServices(): Promise { + const [coreStart, pluginsStart] = await this.getStartServices(); + + const { AnomalyDetectorService } = await import( + '../../application/services/anomaly_detector_service' + ); + const { mlApiServicesProvider } = await import('../../application/services/ml_api_service'); + const { mlResultsServiceProvider } = await import('../../application/services/results_service'); + + const httpService = new HttpService(coreStart.http); + const anomalyDetectorService = new AnomalyDetectorService(httpService); + const mlApiServices = mlApiServicesProvider(httpService); + const mlResultsService = mlResultsServiceProvider(mlApiServices); + + const anomalyExplorerService = new AnomalyExplorerChartsService( + pluginsStart.data.query.timefilter.timefilter, + mlApiServices, + mlResultsService + ); + + return [ + coreStart, + pluginsStart as MlDependencies, + { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + ]; + } + + public async create(initialInput: AnomalyChartsEmbeddableInput, parent?: IContainer) { + const services = await this.getServices(); + const { AnomalyChartsEmbeddable } = await import('./anomaly_charts_embeddable'); + return new AnomalyChartsEmbeddable(initialInput, services, parent); + } +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx new file mode 100644 index 0000000000000..1473a599c2c4b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AnomalyChartsInitializer } from './anomaly_charts_initializer'; +import { I18nProvider } from '@kbn/i18n/react'; +import React from 'react'; +import { getDefaultExplorerChartsPanelTitle } from './anomaly_charts_embeddable'; +const defaultOptions = { wrapper: I18nProvider }; + +describe('AnomalyChartsInitializer', () => { + test('should render anomaly charts initializer', async () => { + const onCreate = jest.fn(); + const onCancel = jest.fn(); + + const jobIds = ['test-job']; + const defaultTitle = getDefaultExplorerChartsPanelTitle(jobIds); + const input = { + maxSeriesToPlot: 12, + }; + const { getByTestId } = render( + onCreate(params)} + onCancel={onCancel} + />, + defaultOptions + ); + const confirmButton = screen.getByText(/Confirm/i).closest('button'); + expect(confirmButton).toBeDefined(); + expect(onCreate).toHaveBeenCalledTimes(0); + + userEvent.click(confirmButton!); + expect(onCreate).toHaveBeenCalledWith({ + panelTitle: defaultTitle, + maxSeriesToPlot: input.maxSeriesToPlot, + }); + + userEvent.clear(await getByTestId('panelTitleInput')); + expect(confirmButton).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx new file mode 100644 index 0000000000000..f32446fd6d9ab --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiFieldNumber, + EuiFieldText, + EuiModal, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AnomalyChartsEmbeddableInput } from '..'; +import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../application/services/anomaly_explorer_charts_service'; + +const MAX_SERIES_ALLOWED = 48; +export interface AnomalyChartsInitializerProps { + defaultTitle: string; + initialInput?: Partial>; + onCreate: (props: { panelTitle: string; maxSeriesToPlot?: number }) => void; + onCancel: () => void; +} + +export const AnomalyChartsInitializer: FC = ({ + defaultTitle, + initialInput, + onCreate, + onCancel, +}) => { + const [panelTitle, setPanelTitle] = useState(defaultTitle); + const [maxSeriesToPlot, setMaxSeriesToPlot] = useState( + initialInput?.maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT + ); + + const isPanelTitleValid = panelTitle.length > 0; + + const isFormValid = isPanelTitleValid && maxSeriesToPlot > 0; + return ( + + + +

+ +

+
+
+ + + + + } + isInvalid={!isPanelTitleValid} + > + setPanelTitle(e.target.value)} + isInvalid={!isPanelTitleValid} + /> + + + + } + > + setMaxSeriesToPlot(parseInt(e.target.value, 10))} + min={0} + max={MAX_SERIES_ALLOWED} + /> + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx new file mode 100644 index 0000000000000..eb39ba4ab29aa --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreStart } from 'kibana/public'; +import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; +import { getDefaultExplorerChartsPanelTitle } from './anomaly_charts_embeddable'; +import { HttpService } from '../../application/services/http_service'; +import { AnomalyChartsEmbeddableInput } from '..'; +import { resolveJobSelection } from '../common/resolve_job_selection'; +import { AnomalyChartsInitializer } from './anomaly_charts_initializer'; + +export async function resolveEmbeddableAnomalyChartsUserInput( + coreStart: CoreStart, + input?: AnomalyChartsEmbeddableInput +): Promise> { + const { http, overlays } = coreStart; + + const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); + + return new Promise(async (resolve, reject) => { + const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); + + const title = input?.title ?? getDefaultExplorerChartsPanelTitle(jobIds); + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); + + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + + resolve({ + jobIds, + title: panelTitle, + maxSeriesToPlot, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) + ); + }); +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx new file mode 100644 index 0000000000000..7e4e91eb2ad0e --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { + EmbeddableAnomalyChartsContainer, + EmbeddableAnomalyChartsContainerProps, +} from './embeddable_anomaly_charts_container'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { I18nProvider } from '@kbn/i18n/react'; +import { AnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import { CoreStart } from 'kibana/public'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import { MlDependencies } from '../../application/app'; +import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; +import { AnomalyChartsEmbeddableInput, AnomalyChartsServices } from '..'; +import { ExplorerAnomaliesContainer } from '../../application/explorer/explorer_charts/explorer_anomalies_container'; +import { createMlResultsServiceMock } from '../../application/services/ml_results_service'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; +import { createAnomalyExplorerChartsServiceMock } from '../../application/services/__mocks__/anomaly_explorer_charts_service'; +import { createAnomalyDetectorServiceMock } from '../../application/services/__mocks__/anomaly_detector_service'; + +jest.mock('./use_anomaly_charts_input_resolver', () => ({ + useAnomalyChartsInputResolver: jest.fn(() => { + return []; + }), +})); + +jest.mock('../../application/explorer/explorer_charts/explorer_anomalies_container', () => ({ + ExplorerAnomaliesContainer: jest.fn(() => { + return null; + }), +})); + +const defaultOptions = { wrapper: I18nProvider }; + +describe('EmbeddableAnomalyChartsContainer', () => { + let embeddableInput: BehaviorSubject>; + let refresh: BehaviorSubject; + let services: jest.Mocked<[CoreStart, MlDependencies, AnomalyChartsServices]>; + let embeddableContext: jest.Mocked; + let trigger: jest.Mocked; + + const onInputChange = jest.fn(); + const onOutputChange = jest.fn(); + + const mockedInput = { + viewMode: 'view', + filters: [], + hidePanelTitles: false, + query: { + language: 'lucene', + query: 'instance:i-d**', + }, + timeRange: { + from: 'now-3y', + to: 'now', + }, + refreshConfig: { + value: 0, + pause: true, + }, + id: 'b5b2f600-9c7e-4f7d-8b82-ee156fffad27', + searchSessionId: 'e8d052f8-0d9a-4d80-819d-fe18d9b314fa', + syncColors: true, + title: 'ML anomaly explorer charts for cw_multi_1', + jobIds: ['cw_multi_1'], + maxSeriesToPlot: 12, + enhancements: {}, + severity: 50, + severityThreshold: 75, + } as AnomalyChartsEmbeddableInput; + + beforeEach(() => { + // we only want to mock some of the functions needed + // @ts-ignore + embeddableContext = { + id: 'test-id', + getInput: jest.fn(), + }; + embeddableContext.getInput.mockReturnValue(mockedInput); + + embeddableInput = new BehaviorSubject({ + id: 'test-explorer-charts-embeddable', + } as Partial); + + trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked; + + const mlStartMock = createMlStartDepsMock(); + mlStartMock.uiActions.getTrigger.mockReturnValue(trigger); + + const coreStartMock = createCoreStartMock(); + const anomalyDetectorServiceMock = createAnomalyDetectorServiceMock(); + + anomalyDetectorServiceMock.getJobs$.mockImplementation((jobId: string[]) => { + if (jobId.includes('invalid-job-id')) { + throw new Error('Invalid job'); + } + return of([ + { + job_id: 'cw_multi_1', + analysis_config: { bucket_span: '15m' }, + }, + ]); + }); + + services = ([ + coreStartMock, + mlStartMock, + { + anomalyDetectorService: anomalyDetectorServiceMock, + anomalyExplorerChartsService: createAnomalyExplorerChartsServiceMock(), + mlResultsService: createMlResultsServiceMock(), + }, + ] as unknown) as EmbeddableAnomalyChartsContainerProps['services']; + }); + + test('should render explorer charts with a valid embeddable input', async () => { + const chartsData = { + chartsPerRow: 2, + seriesToPlot: [], + tooManyBuckets: false, + timeFieldName: '@timestamp', + errorMessages: undefined, + }; + + (useAnomalyChartsInputResolver as jest.Mock).mockReturnValueOnce({ + chartsData, + isLoading: false, + error: undefined, + }); + + render( + } + services={services} + refresh={refresh} + onInputChange={onInputChange} + onOutputChange={onOutputChange} + />, + defaultOptions + ); + + const calledWith = ((ExplorerAnomaliesContainer as unknown) as jest.Mock< + typeof ExplorerAnomaliesContainer + >).mock.calls[0][0]; + + expect(calledWith).toMatchSnapshot(); + }); + + test('should render an error in case it could not fetch the ML charts data', async () => { + (useAnomalyChartsInputResolver as jest.Mock).mockReturnValueOnce({ + chartsData: undefined, + isLoading: false, + error: 'No anomalies', + }); + + const { findByText } = render( + } + services={services} + refresh={refresh} + onInputChange={onInputChange} + onOutputChange={onOutputChange} + />, + defaultOptions + ); + const errorMessage = await findByText('Unable to load the ML anomaly explorer data'); + expect(errorMessage).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx new file mode 100644 index 0000000000000..e1748bd21855b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useState, useMemo, useEffect } from 'react'; +import { EuiCallOut, EuiLoadingChart, EuiResizeObserver, EuiText } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { throttle } from 'lodash'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import type { IAnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import type { + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsEmbeddableServices, +} from '..'; +import type { EntityField, EntityFieldOperation } from '../../../common/util/anomaly_utils'; + +import { ExplorerAnomaliesContainer } from '../../application/explorer/explorer_charts/explorer_anomalies_container'; +import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; +import { optionValueToThreshold } from '../../application/components/controls/select_severity/select_severity'; +import { ANOMALY_THRESHOLD } from '../../../common'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; +import { TimeBuckets } from '../../application/util/time_buckets'; +import { EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER } from '../../ui_actions/triggers'; + +const RESIZE_THROTTLE_TIME_MS = 500; + +export interface EmbeddableAnomalyChartsContainerProps { + id: string; + embeddableContext: InstanceType; + embeddableInput: Observable; + services: AnomalyChartsEmbeddableServices; + refresh: Observable; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; +} + +export const EmbeddableAnomalyChartsContainer: FC = ({ + id, + embeddableContext, + embeddableInput, + services, + refresh, + onInputChange, + onOutputChange, +}) => { + const [chartWidth, setChartWidth] = useState(0); + const [severity, setSeverity] = useState( + optionValueToThreshold( + embeddableContext.getInput().severityThreshold ?? ANOMALY_THRESHOLD.WARNING + ) + ); + const [selectedEntities, setSelectedEntities] = useState(); + const [ + { uiSettings }, + { + data: dataServices, + share: { + urlGenerators: { getUrlGenerator }, + }, + uiActions, + }, + ] = services; + const { timefilter } = dataServices.query.timefilter; + + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, []); + + useEffect(() => { + onInputChange({ + severityThreshold: severity.val, + }); + onOutputChange({ + severity: severity.val, + entityFields: selectedEntities, + }); + }, [severity, selectedEntities]); + + const { chartsData, isLoading: isExplorerLoading, error } = useAnomalyChartsInputResolver( + embeddableInput, + onInputChange, + refresh, + services, + chartWidth, + severity.val + ); + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + setChartWidth(e.width); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); + + if (error) { + return ( + + } + color="danger" + iconType="alert" + style={{ width: '100%' }} + > +

{error.message}

+
+ ); + } + + const addEntityFieldFilter = ( + fieldName: string, + fieldValue: string, + operation: EntityFieldOperation + ) => { + const entity: EntityField = { + fieldName, + fieldValue, + operation, + }; + const uniqueSelectedEntities = [entity]; + setSelectedEntities(uniqueSelectedEntities); + uiActions.getTrigger(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER).exec({ + embeddable: embeddableContext, + data: uniqueSelectedEntities, + }); + }; + + return ( + + {(resizeRef) => ( +
+ {isExplorerLoading && ( + + + + )} + {chartsData !== undefined && isExplorerLoading === false && ( + + )} +
+ )} +
+ ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default EmbeddableAnomalyChartsContainer; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx new file mode 100644 index 0000000000000..38f48ea4a018b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const EmbeddableAnomalyChartsContainer = React.lazy( + () => import('./embeddable_anomaly_charts_container') +); diff --git a/x-pack/plugins/infra/public/components/source_configuration/index.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/index.ts similarity index 56% rename from x-pack/plugins/infra/public/components/source_configuration/index.ts rename to x-pack/plugins/ml/public/embeddables/anomaly_charts/index.ts index 50db601234a8c..7ee763b893367 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/index.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/index.ts @@ -5,6 +5,4 @@ * 2.0. */ -export * from './input_fields'; -export { SourceConfigurationSettings } from './source_configuration_settings'; -export { ViewSourceConfigurationButton } from './view_source_configuration_button'; +export { AnomalyChartsEmbeddableFactory } from './anomaly_charts_embeddable_factory'; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts new file mode 100644 index 0000000000000..efac51edda69f --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { AnomalyChartsEmbeddableInput, AnomalyChartsServices } from '../types'; +import { CoreStart } from 'kibana/public'; +import { MlStartDependencies } from '../../plugin'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import { EmbeddableAnomalyChartsContainerProps } from './embeddable_anomaly_charts_container'; +import moment from 'moment'; +import { createMlResultsServiceMock } from '../../application/services/ml_results_service'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; +import { createAnomalyExplorerChartsServiceMock } from '../../application/services/__mocks__/anomaly_explorer_charts_service'; +import { createAnomalyDetectorServiceMock } from '../../application/services/__mocks__/anomaly_detector_service'; + +jest.mock('../common/process_filters', () => ({ + processFilters: jest.fn(), +})); + +jest.mock('../../application/explorer/explorer_utils', () => ({ + getSelectionInfluencers: jest.fn(() => { + return []; + }), + getSelectionJobIds: jest.fn(() => ['test-job']), + getSelectionTimeRange: jest.fn(() => ({ earliestMs: 1521309543000, latestMs: 1616003942999 })), + loadDataForCharts: jest.fn().mockImplementation(() => + Promise.resolve([ + { + job_id: 'cw_multi_1', + result_type: 'record', + probability: 6.057139142746412e-13, + multi_bucket_impact: -5, + record_score: 89.71961, + initial_record_score: 98.36826274948001, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1572892200000, + partition_field_name: 'instance', + partition_field_value: 'i-d17dcd4c', + function: 'mean', + function_description: 'mean', + typical: [1.6177685422858146], + actual: [7.235333333333333], + field_name: 'CPUUtilization', + influencers: [ + { + influencer_field_name: 'region', + influencer_field_values: ['sa-east-1'], + }, + { + influencer_field_name: 'instance', + influencer_field_values: ['i-d17dcd4c'], + }, + ], + instance: ['i-d17dcd4c'], + region: ['sa-east-1'], + }, + ]) + ), +})); + +describe('useAnomalyChartsInputResolver', () => { + let embeddableInput: BehaviorSubject>; + let refresh: Subject; + let services: [CoreStart, MlStartDependencies, AnomalyChartsServices]; + let onInputChange: jest.Mock; + + const start = moment().subtract(1, 'years'); + const end = moment(); + + beforeEach(() => { + jest.useFakeTimers(); + + const jobIds = ['test-job']; + embeddableInput = new BehaviorSubject({ + id: 'test-explorer-charts-embeddable', + jobIds, + filters: [], + query: { language: 'kuery', query: '' }, + maxSeriesToPlot: 12, + timeRange: { + from: 'now-3y', + to: 'now', + }, + } as Partial); + + refresh = new Subject(); + const anomalyExplorerChartsServiceMock = createAnomalyExplorerChartsServiceMock(); + + anomalyExplorerChartsServiceMock.getTimeBounds.mockReturnValue({ + min: start, + max: end, + }); + + anomalyExplorerChartsServiceMock.getCombinedJobs.mockImplementation(() => + Promise.resolve( + jobIds.map((jobId) => ({ job_id: jobId, analysis_config: {}, datafeed_config: {} })) + ) + ); + + anomalyExplorerChartsServiceMock.getAnomalyData.mockImplementation(() => + Promise.resolve({ + chartsPerRow: 2, + seriesToPlot: [], + tooManyBuckets: false, + timeFieldName: '@timestamp', + errorMessages: undefined, + }) + ); + + const coreStartMock = createCoreStartMock(); + const mlStartMock = createMlStartDepsMock(); + + const anomalyDetectorServiceMock = createAnomalyDetectorServiceMock(); + anomalyDetectorServiceMock.getJobs$.mockImplementation((jobId: string[]) => { + if (jobId.includes('invalid-job-id')) { + throw new Error('Invalid job'); + } + return of([ + { + job_id: 'cw_multi_1', + analysis_config: { bucket_span: '15m' }, + }, + ]); + }); + + services = ([ + coreStartMock, + mlStartMock, + { + anomalyDetectorService: anomalyDetectorServiceMock, + anomalyExplorerService: anomalyExplorerChartsServiceMock, + mlResultsService: createMlResultsServiceMock(), + }, + ] as unknown) as EmbeddableAnomalyChartsContainerProps['services']; + + onInputChange = jest.fn(); + }); + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test('should fetch jobs only when input job ids have been changed', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAnomalyChartsInputResolver( + embeddableInput as Observable, + onInputChange, + refresh, + services, + 1000, + 0 + ) + ); + + expect(result.current.chartsData).toBe(undefined); + expect(result.current.error).toBe(undefined); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(501); + await waitForNextUpdate(); + }); + + const explorerServices = services[2]; + + expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1); + expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(1); + + await act(async () => { + embeddableInput.next({ + id: 'test-explorer-charts-embeddable', + jobIds: ['anotherJobId'], + filters: [], + query: { language: 'kuery', query: '' }, + maxSeriesToPlot: 6, + timeRange: { + from: 'now-3y', + to: 'now', + }, + }); + jest.advanceTimersByTime(501); + await waitForNextUpdate(); + }); + + expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); + expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(2); + }); + + test('should not complete the observable on error', async () => { + const { result } = renderHook(() => + useAnomalyChartsInputResolver( + embeddableInput as Observable, + onInputChange, + refresh, + services, + 1000, + 1 + ) + ); + + await act(async () => { + embeddableInput.next({ + id: 'test-explorer-charts-embeddable', + jobIds: ['invalid-job-id'], + filters: [], + query: { language: 'kuery', query: '' }, + } as Partial); + }); + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts new file mode 100644 index 0000000000000..b114ca89a3288 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { combineLatest, forkJoin, from, Observable, of, Subject } from 'rxjs'; +import { catchError, debounceTime, skipWhile, startWith, switchMap, tap } from 'rxjs/operators'; +import { CoreStart } from 'kibana/public'; +import { TimeBuckets } from '../../application/util/time_buckets'; +import { MlStartDependencies } from '../../plugin'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { + AppStateSelectedCells, + ExplorerJob, + getSelectionInfluencers, + getSelectionJobIds, + getSelectionTimeRange, + loadDataForCharts, +} from '../../application/explorer/explorer_utils'; +import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsServices, +} from '..'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import type { ExplorerChartsData } from '../../application/explorer/explorer_charts/explorer_charts_container_service'; +import { processFilters } from '../common/process_filters'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { getJobsObservable } from '../common/get_jobs_observable'; + +const FETCH_RESULTS_DEBOUNCE_MS = 500; + +export function useAnomalyChartsInputResolver( + embeddableInput: Observable, + onInputChange: (output: Partial) => void, + refresh: Observable, + services: [CoreStart, MlStartDependencies, AnomalyChartsServices], + chartWidth: number, + severity: number +): { chartsData: ExplorerChartsData; isLoading: boolean; error: Error | null | undefined } { + const [ + { uiSettings }, + { data: dataServices }, + { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + ] = services; + const { timefilter } = dataServices.query.timefilter; + + const [chartsData, setChartsData] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const chartWidth$ = useMemo(() => new Subject(), []); + const severity$ = useMemo(() => new Subject(), []); + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, []); + + useEffect(() => { + const subscription = combineLatest([ + getJobsObservable(embeddableInput, anomalyDetectorService, setError), + embeddableInput, + chartWidth$.pipe(skipWhile((v) => !v)), + severity$, + refresh.pipe(startWith(null)), + ]) + .pipe( + tap(setIsLoading.bind(null, true)), + debounceTime(FETCH_RESULTS_DEBOUNCE_MS), + switchMap(([jobs, input, embeddableContainerWidth, severityValue]) => { + if (!jobs) { + // couldn't load the list of jobs + return of(undefined); + } + + const { maxSeriesToPlot, timeRange: timeRangeInput, filters, query } = input; + + const viewBySwimlaneFieldName = OVERALL_LABEL; + + anomalyExplorerService.setTimeRange(timeRangeInput); + + const explorerJobs: ExplorerJob[] = jobs.map((job) => { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + return { + id: job.job_id, + selected: true, + bucketSpanSeconds: bucketSpan!.asSeconds(), + }; + }); + + let influencersFilterQuery: InfluencersFilterQuery; + try { + influencersFilterQuery = processFilters(filters, query); + } catch (e) { + // handle query syntax errors + setError(e); + return of(undefined); + } + + const bounds = anomalyExplorerService.getTimeBounds(); + + // Can be from input time range or from the timefilter bar + const selections: AppStateSelectedCells = { + lanes: [OVERALL_LABEL], + times: [bounds.min?.unix()!, bounds.max?.unix()!], + type: SWIMLANE_TYPE.OVERALL, + }; + + const selectionInfluencers = getSelectionInfluencers(selections, viewBySwimlaneFieldName); + + const jobIds = getSelectionJobIds(selections, explorerJobs); + + const bucketInterval = timeBuckets.getInterval(); + + const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds); + return forkJoin({ + combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds), + anomalyChartRecords: loadDataForCharts( + mlResultsService, + jobIds, + timeRange.earliestMs, + timeRange.latestMs, + selectionInfluencers, + selections, + influencersFilterQuery, + false + ), + }).pipe( + switchMap(({ combinedJobs, anomalyChartRecords }) => { + const combinedJobRecords: Record< + string, + CombinedJob + > = (combinedJobs as CombinedJob[]).reduce((acc, job) => { + return { ...acc, [job.job_id]: job }; + }, {}); + + return forkJoin({ + chartsData: from( + anomalyExplorerService.getAnomalyData( + undefined, + combinedJobRecords, + embeddableContainerWidth, + anomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilter, + severityValue, + maxSeriesToPlot + ) + ), + }); + }) + ); + }), + catchError((e) => { + setError(e.body); + return of(undefined); + }) + ) + .subscribe((results) => { + if (results !== undefined) { + setError(null); + setChartsData(results.chartsData); + setIsLoading(false); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + useEffect(() => { + chartWidth$.next(chartWidth); + }, [chartWidth]); + + useEffect(() => { + severity$.next(severity); + }, [severity]); + + return { chartsData, isLoading, error }; +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index 50aa99e2b8d17..7f9e99f3a0c8e 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -22,8 +22,8 @@ import { AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, } from '..'; - -export const getDefaultPanelTitle = (jobIds: JobId[]) => +import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; +export const getDefaultSwimlanePanelTitle = (jobIds: JobId[]) => i18n.translate('xpack.ml.swimlaneEmbeddable.title', { defaultMessage: 'ML anomaly swim lane for {jobIds}', values: { jobIds: jobIds.join(', ') }, @@ -62,7 +62,7 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< ReactDOM.render( - + }> > { - const { - http, - uiSettings, - overlays, - application: { currentAppId$ }, - } = coreStart; + const { http, overlays } = coreStart; const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); return new Promise(async (resolve, reject) => { - const maps = { - groupsMap: getInitialGroupsMap([]), - jobsMap: {}, - }; + const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); - const tzConfig = uiSettings.get('dateFormat:tz'); - const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); + const title = input?.title ?? getDefaultSwimlanePanelTitle(jobIds); - const selectedIds = input?.jobIds; + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); - const flyoutSession = coreStart.overlays.openFlyout( - toMountPoint( - - { - flyoutSession.close(); - reject(); - }} - onSelectionConfirmed={async ({ jobIds, groups }) => { - const title = input?.title ?? getDefaultPanelTitle(jobIds); - - const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); - - const influencers = anomalyDetectorService.extractInfluencers(jobs); - influencers.push(VIEW_BY_JOB_LABEL); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); - await flyoutSession.close(); - - const modalSession = overlays.openModal( - toMountPoint( - { - modalSession.close(); - resolve({ jobIds, title: panelTitle, swimlaneType, viewBy }); - }} - onCancel={() => { - modalSession.close(); - reject(); - }} - /> - ) - ); - }} - maps={maps} - /> - - ), - { - 'data-test-subj': 'mlFlyoutJobSelector', - ownFocus: true, - closeButtonAriaLabel: 'jobSelectorFlyout', - } + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + resolve({ jobIds, title: panelTitle, swimlaneType, viewBy }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) ); - - // Close the flyout when user navigates out of the dashboard plugin - currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { - if (appId !== DashboardConstants.DASHBOARDS_ID) { - flyoutSession.close(); - } - }); }); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 560e373eb281c..00f4da09bbe0e 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx @@ -19,9 +19,10 @@ import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { SwimlaneContainer } from '../../application/explorer/swimlane_container'; import { MlDependencies } from '../../application/app'; -import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneServices } from '..'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -56,14 +57,12 @@ describe('ExplorerSwimlaneContainer', () => { trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked; - const uiActionsMock = uiActionsPluginMock.createStartContract(); - uiActionsMock.getTrigger.mockReturnValue(trigger); + const mlStartMock = createMlStartDepsMock(); + mlStartMock.uiActions.getTrigger.mockReturnValue(trigger); services = ([ - {}, - { - uiActions: uiActionsMock, - }, + createCoreStartMock(), + mlStartMock, ] as unknown) as ExplorerSwimlaneContainerProps['services']; }); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index fb47a2684a015..d671bff90b31f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -47,6 +47,7 @@ export const EmbeddableSwimLaneContainer: FC = ( onOutputChange, }) => { const [chartWidth, setChartWidth] = useState(0); + const [fromPage, setFromPage] = useState(1); const [{}, { uiActions }] = services; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 3fffd1588b9b9..4d2e2406376e2 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { processFilters, useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { CoreStart, IUiSettingsClient } from 'kibana/public'; @@ -157,146 +157,3 @@ describe('useSwimlaneInputResolver', () => { expect(result.current[6]?.message).toBe('Invalid job'); }); }); - -describe('processFilters', () => { - test('should format embeddable input to es query', () => { - expect( - processFilters( - [ - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - type: 'phrases', - key: 'instance', - value: 'i-20d061fa', - params: ['i-20d061fa'], - alias: null, - negate: false, - disabled: false, - }, - query: { - bool: { - should: [ - { - match_phrase: { - instance: 'i-20d061fa', - }, - }, - ], - minimum_should_match: 1, - }, - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: true, - disabled: false, - type: 'phrase', - key: 'instance', - params: { - query: 'i-16fd8d2a', - }, - }, - query: { - match_phrase: { - instance: 'i-16fd8d2a', - }, - }, - - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: false, - disabled: false, - type: 'exists', - key: 'instance', - value: 'exists', - }, - exists: { - field: 'instance', - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: false, - disabled: true, - type: 'exists', - key: 'instance', - value: 'exists', - }, - exists: { - field: 'region', - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - ], - { - language: 'kuery', - query: 'instance : "i-088147ac"', - } - ) - ).toEqual({ - bool: { - must: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - instance: 'i-088147ac', - }, - }, - ], - }, - }, - { - bool: { - should: [ - { - match_phrase: { - instance: 'i-20d061fa', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - exists: { - field: 'instance', - }, - }, - ], - must_not: [ - { - match_phrase: { - instance: 'i-16fd8d2a', - }, - }, - ], - }, - }); - }); -}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index fa0cccda99d22..4574c7e859c08 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -7,13 +7,11 @@ import { useEffect, useMemo, useState } from 'react'; import { combineLatest, from, Observable, of, Subject } from 'rxjs'; -import { isEqual } from 'lodash'; import { catchError, debounceTime, distinctUntilChanged, map, - pluck, skipWhile, startWith, switchMap, @@ -28,39 +26,22 @@ import { SWIMLANE_TYPE, SwimlaneType, } from '../../application/explorer/explorer_constants'; -import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; -import { Query } from '../../../../../../src/plugins/data/common/query'; -import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; import { parseInterval } from '../../../common/util/parse_interval'; -import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; -import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../ui_actions/constants'; import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, } from '..'; +import { processFilters } from '../common/process_filters'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../..'; +import { getJobsObservable } from '../common/get_jobs_observable'; const FETCH_RESULTS_DEBOUNCE_MS = 500; -function getJobsObservable( - embeddableInput: Observable, - anomalyDetectorService: AnomalyDetectorService, - setErrorHandler: (e: Error) => void -) { - return embeddableInput.pipe( - pluck('jobIds'), - distinctUntilChanged(isEqual), - switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), - catchError((e) => { - setErrorHandler(e.body ?? e); - return of(undefined); - }) - ); -} - export function useSwimlaneInputResolver( embeddableInput: Observable, onInputChange: (output: Partial) => void, @@ -149,7 +130,7 @@ export function useSwimlaneInputResolver( let appliedFilters: any; try { - appliedFilters = processFilters(filters, query); + appliedFilters = processFilters(filters, query, CONTROLLED_BY_SWIM_LANE_FILTER); } catch (e) { // handle query syntax errors setError(e); @@ -242,44 +223,3 @@ export function useSwimlaneInputResolver( error, ]; } - -export function processFilters(filters: Filter[], query: Query) { - const inputQuery = - query.language === 'kuery' - ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)) - : query.query; - - const must = [inputQuery]; - const mustNot = []; - for (const filter of filters) { - // ignore disabled filters as well as created by swim lane selection - if (filter.meta.disabled || filter.meta.controlledBy === CONTROLLED_BY_SWIM_LANE_FILTER) - continue; - - const { - meta: { negate, type, key: fieldName }, - } = filter; - - let filterQuery = filter.query; - - if (filterQuery === undefined && type === 'exists') { - filterQuery = { - exists: { - field: fieldName, - }, - }; - } - - if (negate) { - mustNot.push(filterQuery); - } else { - must.push(filterQuery); - } - } - return { - bool: { - must, - must_not: mustNot, - }, - }; -} diff --git a/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx b/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx new file mode 100644 index 0000000000000..01644efd6652c --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +export const EmbeddableLoading = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts new file mode 100644 index 0000000000000..6bdec30340b76 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, pluck, switchMap } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import { AnomalyChartsEmbeddableInput, AnomalySwimlaneEmbeddableInput } from '../types'; +import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; + +export function getJobsObservable( + embeddableInput: Observable, + anomalyDetectorService: AnomalyDetectorService, + setErrorHandler: (e: Error) => void +) { + return embeddableInput.pipe( + pluck('jobIds'), + distinctUntilChanged(isEqual), + switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), + catchError((e) => { + setErrorHandler(e.body ?? e); + return of(undefined); + }) + ); +} diff --git a/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts b/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts new file mode 100644 index 0000000000000..262b744786d97 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts @@ -0,0 +1,288 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { processFilters } from './process_filters'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../..'; + +describe('processFilters', () => { + test('should format kql embeddable input to es query', () => { + expect( + processFilters( + [ + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + type: 'phrases', + key: 'instance', + value: 'i-20d061fa', + params: ['i-20d061fa'], + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'instance', + params: { + query: 'i-16fd8d2a', + }, + }, + query: { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'instance', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: true, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'region', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + ], + { + language: 'kuery', + query: 'instance : "i-088147ac"', + }, + CONTROLLED_BY_SWIM_LANE_FILTER + ) + ).toEqual({ + bool: { + must: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + instance: 'i-088147ac', + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + exists: { + field: 'instance', + }, + }, + ], + must_not: [ + { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + ], + }, + }); + }); + + test('should format lucene embeddable input to es query', () => { + expect( + processFilters( + [ + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + type: 'phrases', + key: 'instance', + value: 'i-20d061fa', + params: ['i-20d061fa'], + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'instance', + params: { + query: 'i-16fd8d2a', + }, + }, + query: { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'instance', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: true, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'region', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + ], + { + language: 'lucene', + query: 'instance:i-d**', + }, + CONTROLLED_BY_SWIM_LANE_FILTER + ) + ).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'instance:i-d**', + }, + }, + { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + exists: { + field: 'instance', + }, + }, + ], + must_not: [ + { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/common/process_filters.ts b/x-pack/plugins/ml/public/embeddables/common/process_filters.ts new file mode 100644 index 0000000000000..8ff75205b4d48 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/process_filters.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; +import { Query } from '../../../../../../src/plugins/data/common/query'; +import { esKuery, esQuery } from '../../../../../../src/plugins/data/public'; + +export function processFilters(filters: Filter[], query: Query, controlledBy?: string) { + const inputQuery = + query.language === 'kuery' + ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)) + : esQuery.luceneStringToDsl(query.query); + + const must = [inputQuery]; + const mustNot = []; + + for (const filter of filters) { + // ignore disabled filters as well as created by swim lane selection + if ( + filter.meta.disabled || + (controlledBy !== undefined && filter.meta.controlledBy === controlledBy) + ) + continue; + + const { + meta: { negate, type, key: fieldName }, + } = filter; + + let filterQuery = filter.query; + + if (filterQuery === undefined && type === 'exists') { + filterQuery = { + exists: { + field: fieldName, + }, + }; + } + + if (negate) { + mustNot.push(filterQuery); + } else { + must.push(filterQuery); + } + } + return { + bool: { + must, + must_not: mustNot, + }, + }; +} diff --git a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx new file mode 100644 index 0000000000000..8499ab624f790 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx @@ -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 { CoreStart } from 'kibana/public'; +import moment from 'moment'; +import { takeUntil } from 'rxjs/operators'; +import { from } from 'rxjs'; +import React from 'react'; +import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector'; +import { + KibanaContextProvider, + toMountPoint, +} from '../../../../../../src/plugins/kibana_react/public'; +import { getMlGlobalServices } from '../../application/app'; +import { JobSelectorFlyoutContent } from '../../application/components/job_selector/job_selector_flyout'; +import { DashboardConstants } from '../../../../../../src/plugins/dashboard/public'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; + +/** + * Handles Anomaly detection jobs selection by a user. + * Intended to use independently of the ML app context, + * for instance on the dashboard for embeddables initialization. + * + * @param coreStart + * @param selectedJobIds + */ +export async function resolveJobSelection( + coreStart: CoreStart, + selectedJobIds?: JobId[] +): Promise<{ jobIds: string[]; groups: Array<{ groupId: string; jobIds: string[] }> }> { + const { + http, + uiSettings, + application: { currentAppId$ }, + } = coreStart; + + return new Promise(async (resolve, reject) => { + const maps = { + groupsMap: getInitialGroupsMap([]), + jobsMap: {}, + }; + + const tzConfig = uiSettings.get('dateFormat:tz'); + const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); + + const flyoutSession = coreStart.overlays.openFlyout( + toMountPoint( + + { + flyoutSession.close(); + reject(); + }} + onSelectionConfirmed={async ({ jobIds, groups }) => { + await flyoutSession.close(); + resolve({ jobIds, groups }); + }} + maps={maps} + /> + + ), + { + 'data-test-subj': 'mlFlyoutJobSelector', + ownFocus: true, + closeButtonAriaLabel: 'jobSelectorFlyout', + } + ); + + // Close the flyout when user navigates out of the dashboard plugin + currentAppId$.pipe(takeUntil(from(flyoutSession.onClose))).subscribe((appId) => { + if (appId !== DashboardConstants.DASHBOARDS_ID) { + flyoutSession.close(); + } + }); + }); +} diff --git a/x-pack/plugins/ml/public/embeddables/constants.ts b/x-pack/plugins/ml/public/embeddables/constants.ts index c50264ccccd97..8307eeda23ec6 100644 --- a/x-pack/plugins/ml/public/embeddables/constants.ts +++ b/x-pack/plugins/ml/public/embeddables/constants.ts @@ -6,3 +6,4 @@ */ export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; +export const ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE = 'ml_anomaly_charts'; diff --git a/x-pack/plugins/ml/public/embeddables/index.ts b/x-pack/plugins/ml/public/embeddables/index.ts index b1dd3ac0a4a17..2011d7217094b 100644 --- a/x-pack/plugins/ml/public/embeddables/index.ts +++ b/x-pack/plugins/ml/public/embeddables/index.ts @@ -8,6 +8,7 @@ import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane'; import type { MlCoreSetup } from '../plugin'; import type { EmbeddableSetup } from '../../../../../src/plugins/embeddable/public'; +import { AnomalyChartsEmbeddableFactory } from './anomaly_charts'; export * from './constants'; export * from './types'; @@ -20,4 +21,8 @@ export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSet anomalySwimlaneEmbeddableFactory.type, anomalySwimlaneEmbeddableFactory ); + + const anomalyChartsFactory = new AnomalyChartsEmbeddableFactory(core.getStartServices); + + embeddable.registerEmbeddableFactory(anomalyChartsFactory.type, anomalyChartsFactory); } diff --git a/x-pack/plugins/ml/public/embeddables/types.ts b/x-pack/plugins/ml/public/embeddables/types.ts index 712aba707f4c7..05aea1770a415 100644 --- a/x-pack/plugins/ml/public/embeddables/types.ts +++ b/x-pack/plugins/ml/public/embeddables/types.ts @@ -23,6 +23,15 @@ import type { AnomalyDetectorService } from '../application/services/anomaly_det import type { AnomalyTimelineService } from '../application/services/anomaly_timeline_service'; import type { MlDependencies } from '../application/app'; import type { AppStateSelectedCells } from '../application/explorer/explorer_utils'; +import { AnomalyExplorerChartsService } from '../application/services/anomaly_explorer_charts_service'; +import { EntityField } from '../../common/util/anomaly_utils'; +import { isPopulatedObject } from '../../common/util/object_utils'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, +} from './constants'; +import { MlResultsService } from '../application/services/results_service'; +import { IndexPattern } from '../../../../../src/plugins/data/common/index_patterns/index_patterns'; export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; @@ -69,3 +78,60 @@ export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { */ data?: AppStateSelectedCells; } + +export function isSwimLaneEmbeddable(arg: unknown): arg is SwimLaneDrilldownContext { + return ( + isPopulatedObject(arg) && + arg.hasOwnProperty('embeddable') && + arg.embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE + ); +} + +/** + * Anomaly Explorer + */ +export interface AnomalyChartsEmbeddableCustomInput { + jobIds: JobId[]; + maxSeriesToPlot: number; + + // Embeddable inputs which are not included in the default interface + filters: Filter[]; + query: Query; + refreshConfig: RefreshInterval; + timeRange: TimeRange; + severityThreshold?: number; +} + +export type AnomalyChartsEmbeddableInput = EmbeddableInput & AnomalyChartsEmbeddableCustomInput; + +export interface AnomalyChartsServices { + anomalyDetectorService: AnomalyDetectorService; + anomalyExplorerService: AnomalyExplorerChartsService; + mlResultsService: MlResultsService; +} + +export type AnomalyChartsEmbeddableServices = [CoreStart, MlDependencies, AnomalyChartsServices]; +export interface AnomalyChartsCustomOutput { + entityFields?: EntityField[]; + severity?: number; + indexPatterns?: IndexPattern[]; +} +export type AnomalyChartsEmbeddableOutput = EmbeddableOutput & AnomalyChartsCustomOutput; +export interface EditAnomalyChartsPanelContext { + embeddable: IEmbeddable; +} +export interface AnomalyChartsFieldSelectionContext extends EditAnomalyChartsPanelContext { + /** + * Optional fields selected using anomaly charts + */ + data?: EntityField[]; +} +export function isAnomalyExplorerEmbeddable( + arg: unknown +): arg is AnomalyChartsFieldSelectionContext { + return ( + isPopulatedObject(arg) && + arg.hasOwnProperty('embeddable') && + arg.embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE + ); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx new file mode 100644 index 0000000000000..03b6459f82f58 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { MlCoreSetup } from '../plugin'; +import { Filter, FilterStateStore } from '../../../../../src/plugins/data/common'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsFieldSelectionContext, +} from '../embeddables'; +import { CONTROLLED_BY_ANOMALY_CHARTS_FILTER } from './constants'; +import { ENTITY_FIELD_OPERATIONS } from '../../common/util/anomaly_utils'; + +export const APPLY_ENTITY_FIELD_FILTERS_ACTION = 'applyEntityFieldFiltersAction'; + +export function createApplyEntityFieldFiltersAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-entity-field-filters', + type: APPLY_ENTITY_FIELD_FILTERS_ACTION, + getIconType(context: AnomalyChartsFieldSelectionContext): string { + return 'filter'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.applyEntityFieldsFiltersTitle', { + defaultMessage: 'Filter for value', + }); + }, + async execute({ data }) { + if (!data) { + throw new Error('No entities provided'); + } + const [, pluginStart] = await getStartServices(); + const filterManager = pluginStart.data.query.filterManager; + + filterManager.addFilters( + data + .filter((d) => d.operation === ENTITY_FIELD_OPERATIONS.ADD) + .map(({ fieldName, fieldValue }) => { + return { + $state: { + store: FilterStateStore.APP_STATE, + }, + meta: { + alias: i18n.translate('xpack.ml.actions.entityFieldFilterAliasLabel', { + defaultMessage: '{labelValue}', + values: { + labelValue: `${fieldName}:${fieldValue}`, + }, + }), + controlledBy: CONTROLLED_BY_ANOMALY_CHARTS_FILTER, + negate: false, + disabled: false, + type: 'phrase', + key: fieldName, + params: { + query: fieldValue, + }, + }, + query: { + match_phrase: { + [fieldName]: fieldValue, + }, + }, + }; + }) + ); + + data + .filter((field) => field.operation === ENTITY_FIELD_OPERATIONS.REMOVE) + .forEach((field) => { + const filter = filterManager + .getFilters() + .find( + (f) => f.meta.key === field.fieldName && f.meta.params.query === field.fieldValue + ); + if (filter) { + filterManager.removeFilter(filter); + } + }); + }, + async isCompatible({ embeddable, data }) { + return embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE && data !== undefined; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx index e3d2ca4ce0de1..0642687e2926c 100644 --- a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -44,7 +44,7 @@ export function createApplyInfluencerFiltersAction( }, meta: { alias: i18n.translate('xpack.ml.actions.influencerFilterAliasLabel', { - defaultMessage: 'Influencer {labelValue}', + defaultMessage: '{labelValue}', values: { labelValue: `${data.viewByFieldName}:${influencerValue}`, }, diff --git a/x-pack/plugins/ml/public/ui_actions/constants.ts b/x-pack/plugins/ml/public/ui_actions/constants.ts index 6dc3f03d10fd9..459f342dc4527 100644 --- a/x-pack/plugins/ml/public/ui_actions/constants.ts +++ b/x-pack/plugins/ml/public/ui_actions/constants.ts @@ -6,3 +6,4 @@ */ export const CONTROLLED_BY_SWIM_LANE_FILTER = 'anomaly-swim-lane'; +export const CONTROLLED_BY_ANOMALY_CHARTS_FILTER = 'anomaly-charts'; diff --git a/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx new file mode 100644 index 0000000000000..1895ed3acf981 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { MlCoreSetup } from '../plugin'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + EditAnomalyChartsPanelContext, +} from '../embeddables'; + +export const EDIT_ANOMALY_CHARTS_PANEL_ACTION = 'editAnomalyChartsPanelAction'; + +export function createEditAnomalyChartsPanelAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'edit-anomaly-charts', + type: EDIT_ANOMALY_CHARTS_PANEL_ACTION, + getIconType(context): string { + return 'pencil'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.editAnomalyChartsTitle', { + defaultMessage: 'Edit anomaly charts', + }), + async execute({ embeddable }) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + const [coreStart] = await getStartServices(); + + try { + const { resolveEmbeddableAnomalyChartsUserInput } = await import( + '../embeddables/anomaly_charts/anomaly_charts_setup_flyout' + ); + + const result = await resolveEmbeddableAnomalyChartsUserInput( + coreStart, + embeddable.getInput() + ); + embeddable.updateInput(result); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible({ embeddable }) { + return ( + embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE && + embeddable.getInput().viewMode === ViewMode.EDIT + ); + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 6fec66382e3f9..46e928e5f55eb 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -12,17 +12,21 @@ import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; -import { SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger } from './triggers'; +import { + entityFieldSelectionTrigger, + EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, + SWIM_LANE_SELECTION_TRIGGER, + swimLaneSelectionTrigger, +} from './triggers'; import { createApplyTimeRangeSelectionAction } from './apply_time_range_action'; import { createClearSelectionAction } from './clear_selection_action'; - +import { createEditAnomalyChartsPanelAction } from './edit_anomaly_charts_panel_action'; +import { createApplyEntityFieldFiltersAction } from './apply_entity_filters_action'; export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { EDIT_SWIMLANE_PANEL_ACTION } from './edit_swimlane_panel_action'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; - -export { SWIM_LANE_SELECTION_TRIGGER } from './triggers'; - +export { SWIM_LANE_SELECTION_TRIGGER }; /** * Register ML UI actions */ @@ -34,24 +38,31 @@ export function registerMlUiActions( const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); + const applyEntityFieldFilterAction = createApplyEntityFieldFiltersAction(core.getStartServices); const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); + const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); uiActions.registerAction(openInExplorerAction); uiActions.registerAction(applyInfluencerFiltersAction); + uiActions.registerAction(applyEntityFieldFilterAction); uiActions.registerAction(applyTimeRangeSelectionAction); uiActions.registerAction(clearSelectionAction); + uiActions.registerAction(editExplorerPanelAction); // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, editExplorerPanelAction.id); uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInExplorerAction.id); uiActions.registerTrigger(swimLaneSelectionTrigger); + uiActions.registerTrigger(entityFieldSelectionTrigger); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); + uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx index 614df96b59963..7353502f95b47 100644 --- a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx @@ -9,12 +9,21 @@ import { i18n } from '@kbn/i18n'; import { createAction } from '../../../../../src/plugins/ui_actions/public'; import { MlCoreSetup } from '../plugin'; import { ML_APP_URL_GENERATOR } from '../../common/constants/ml_url_generator'; -import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, SwimLaneDrilldownContext } from '../embeddables'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + AnomalyChartsFieldSelectionContext, + isAnomalyExplorerEmbeddable, + isSwimLaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables'; +import { ENTITY_FIELD_OPERATIONS } from '../../common/util/anomaly_utils'; +import { ExplorerAppState } from '../../common/types/ml_url_generator'; export const OPEN_IN_ANOMALY_EXPLORER_ACTION = 'openInAnomalyExplorerAction'; export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getStartServices']) { - return createAction({ + return createAction({ id: 'open-in-anomaly-explorer', type: OPEN_IN_ANOMALY_EXPLORER_ACTION, getIconType(context): string { @@ -25,42 +34,98 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta defaultMessage: 'Open in Anomaly Explorer', }); }, - async getHref({ embeddable, data }): Promise { + async getHref(context): Promise { const [, pluginsStart] = await getStartServices(); const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); - const { jobIds, timeRange, viewBy } = embeddable.getInput(); - const { perPage, fromPage } = embeddable.getOutput(); - return urlGenerator.createUrl({ - page: 'explorer', - pageState: { - jobIds, - timeRange, - mlExplorerSwimlane: { - viewByFromPage: fromPage, - viewByPerPage: perPage, - viewByFieldName: viewBy, - ...(data - ? { - selectedType: data.type, - selectedTimes: data.times, - selectedLanes: data.lanes, - } - : {}), + if (isSwimLaneEmbeddable(context)) { + const { embeddable, data } = context; + + const { jobIds, timeRange, viewBy } = embeddable.getInput(); + const { perPage, fromPage } = embeddable.getOutput(); + + return urlGenerator.createUrl({ + page: 'explorer', + pageState: { + jobIds, + timeRange, + mlExplorerSwimlane: { + viewByFromPage: fromPage, + viewByPerPage: perPage, + viewByFieldName: viewBy, + ...(data + ? { + selectedType: data.type, + selectedTimes: data.times, + selectedLanes: data.lanes, + } + : {}), + }, }, - }, - }); + }); + } else if (isAnomalyExplorerEmbeddable(context)) { + const { embeddable } = context; + + const { jobIds, timeRange } = embeddable.getInput(); + const { entityFields } = embeddable.getOutput(); + + let mlExplorerFilter: ExplorerAppState['mlExplorerFilter'] | undefined; + if ( + Array.isArray(entityFields) && + entityFields.length === 1 && + entityFields[0].operation === ENTITY_FIELD_OPERATIONS.ADD + ) { + const { fieldName, fieldValue } = entityFields[0]; + if (fieldName !== undefined && fieldValue !== undefined) { + const influencersFilterQuery = { + bool: { + should: [ + { + match_phrase: { + [fieldName]: fieldValue, + }, + }, + ], + minimum_should_match: 1, + }, + }; + const filteredFields = [fieldName, fieldValue]; + mlExplorerFilter = { + influencersFilterQuery, + filterActive: true, + queryString: `${fieldName}:"${fieldValue}"`, + ...(Array.isArray(filteredFields) ? { filteredFields } : {}), + }; + } + } + return urlGenerator.createUrl({ + page: 'explorer', + pageState: { + jobIds, + timeRange, + ...(mlExplorerFilter ? { mlExplorerFilter } : {}), + query: {}, + }, + }); + } }, - async execute({ embeddable, data }) { - if (!embeddable) { + async execute(context) { + if (!context.embeddable) { throw new Error('Not possible to execute an action without the embeddable context'); } const [{ application }] = await getStartServices(); - const anomalyExplorerUrl = await this.getHref!({ embeddable, data }); - await application.navigateToUrl(anomalyExplorerUrl!); + const anomalyExplorerUrl = await this.getHref!(context); + if (anomalyExplorerUrl) { + await application.navigateToUrl(anomalyExplorerUrl!); + } }, - async isCompatible({ embeddable }: SwimLaneDrilldownContext) { - return embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; + async isCompatible({ + embeddable, + }: SwimLaneDrilldownContext | AnomalyChartsFieldSelectionContext) { + return ( + embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE || + embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE + ); }, }); } diff --git a/x-pack/plugins/ml/public/ui_actions/triggers.ts b/x-pack/plugins/ml/public/ui_actions/triggers.ts index 7763facbdc158..05074ce6df271 100644 --- a/x-pack/plugins/ml/public/ui_actions/triggers.ts +++ b/x-pack/plugins/ml/public/ui_actions/triggers.ts @@ -16,3 +16,12 @@ export const swimLaneSelectionTrigger: Trigger = { title: '', description: 'Swim lane selection triggered', }; + +export const EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER = 'EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER'; +export const entityFieldSelectionTrigger: Trigger = { + id: EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', + description: 'Entity field selection triggered', +}; diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index ce61541896721..81529669749bc 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -398,6 +398,7 @@ export function alertingServiceProvider(mlClient: MlClient, esClient: Elasticsea const response = await mlClient.anomalySearch( { + // @ts-expect-error body: requestBody, }, jobIds @@ -425,7 +426,8 @@ export function alertingServiceProvider(mlClient: MlClient, esClient: Elasticsea .filter((v) => v.doc_count > 0 && v[resultsLabel.aggGroupLabel].doc_count > 0) // Map response .map(formatter) - : [formatter(result as AggResultsResponse)] + : // @ts-expect-error + [formatter(result as AggResultsResponse)] ).filter(isDefined); }; diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index 278dd19f74acc..6e76a536feb25 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -6,17 +6,10 @@ */ import { IScopedClusterClient } from 'kibana/server'; - import { JobSavedObjectService } from '../../saved_objects'; import { JobType } from '../../../common/types/saved_objects'; -import { - Job, - JobStats, - Datafeed, - DatafeedStats, -} from '../../../common/types/anomaly_detection_jobs'; -import { Calendar } from '../../../common/types/calendars'; +import { Job, Datafeed } from '../../../common/types/anomaly_detection_jobs'; import { searchProvider } from './search'; import { DataFrameAnalyticsConfig } from '../../../common/types/data_frame_analytics'; @@ -109,7 +102,7 @@ export function getMlClient( // similar to groupIdsCheck above, however we need to load the jobs first to get the groups information const ids = getADJobIdsFromRequest(p); if (ids.length) { - const { body } = await mlClient.getJobs<{ jobs: Job[] }>(...p); + const { body } = await mlClient.getJobs(...p); await groupIdsCheck(p, body.jobs, filteredJobIds); } } @@ -131,6 +124,7 @@ export function getMlClient( } } + // @ts-expect-error promise and TransportRequestPromise are incompatible. missing abort return { async closeJob(...p: Parameters) { await jobIdsCheck('anomaly-detector', p); @@ -152,7 +146,7 @@ export function getMlClient( // deleted initially and could still fail. return resp; }, - async deleteDatafeed(...p: any) { + async deleteDatafeed(...p: Parameters) { await datafeedIdsCheck(p); const resp = await mlClient.deleteDatafeed(...p); const [datafeedId] = getDatafeedIdsFromRequest(p); @@ -213,7 +207,7 @@ export function getMlClient( return mlClient.getCalendarEvents(...p); }, async getCalendars(...p: Parameters) { - const { body } = await mlClient.getCalendars<{ calendars: Calendar[] }, any>(...p); + const { body } = await mlClient.getCalendars(...p); const { body: { jobs: allJobs }, } = await mlClient.getJobs<{ jobs: Job[] }>(); @@ -263,9 +257,9 @@ export function getMlClient( // this should use DataFrameAnalyticsStats, but needs a refactor to move DataFrameAnalyticsStats to common await jobIdsCheck('data-frame-analytics', p, true); try { - const { body } = await mlClient.getDataFrameAnalyticsStats<{ - data_frame_analytics: DataFrameAnalyticsConfig[]; - }>(...p); + const { body } = ((await mlClient.getDataFrameAnalyticsStats(...p)) as unknown) as { + body: { data_frame_analytics: DataFrameAnalyticsConfig[] }; + }; const jobs = await jobSavedObjectService.filterJobsForSpace( 'data-frame-analytics', body.data_frame_analytics, @@ -282,8 +276,8 @@ export function getMlClient( async getDatafeedStats(...p: Parameters) { await datafeedIdsCheck(p, true); try { - const { body } = await mlClient.getDatafeedStats<{ datafeeds: DatafeedStats[] }>(...p); - const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( + const { body } = await mlClient.getDatafeedStats(...p); + const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', body.datafeeds, 'datafeed_id' @@ -299,7 +293,7 @@ export function getMlClient( async getDatafeeds(...p: Parameters) { await datafeedIdsCheck(p, true); try { - const { body } = await mlClient.getDatafeeds<{ datafeeds: Datafeed[] }>(...p); + const { body } = await mlClient.getDatafeeds(...p); const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', body.datafeeds, @@ -322,8 +316,8 @@ export function getMlClient( }, async getJobStats(...p: Parameters) { try { - const { body } = await mlClient.getJobStats<{ jobs: JobStats[] }>(...p); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const { body } = await mlClient.getJobStats(...p); + const jobs = await jobSavedObjectService.filterJobsForSpace( 'anomaly-detector', body.jobs, 'job_id' diff --git a/x-pack/plugins/ml/server/lib/ml_client/search.ts b/x-pack/plugins/ml/server/lib/ml_client/search.ts index 158de0017fbbf..3062a70d9a975 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/search.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/search.ts @@ -7,11 +7,10 @@ import Boom from '@hapi/boom'; import { IScopedClusterClient } from 'kibana/server'; -import { RequestParams, ApiResponse } from '@elastic/elasticsearch'; +import { estypes, ApiResponse } from '@elastic/elasticsearch'; import { JobSavedObjectService } from '../../saved_objects'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; -import type { SearchResponse7 } from '../../../common/types/es_client'; import type { JobType } from '../../../common/types/saved_objects'; export function searchProvider( @@ -29,12 +28,12 @@ export function searchProvider( } async function anomalySearch( - searchParams: RequestParams.Search, + searchParams: estypes.SearchRequest, jobIds: string[] - ): Promise>> { + ): Promise>> { await jobIdsCheck('anomaly-detector', jobIds); const { asInternalUser } = client; - const resp = await asInternalUser.search>({ + const resp = await asInternalUser.search({ ...searchParams, index: ML_RESULTS_INDEX_PATTERN, }); diff --git a/x-pack/plugins/ml/server/lib/node_utils.ts b/x-pack/plugins/ml/server/lib/node_utils.ts index b76245fb9796c..82e5d7f469849 100644 --- a/x-pack/plugins/ml/server/lib/node_utils.ts +++ b/x-pack/plugins/ml/server/lib/node_utils.ts @@ -17,7 +17,7 @@ export async function getMlNodeCount(client: IScopedClusterClient): Promise { if (body.nodes[k].attributes !== undefined) { - const maxOpenJobs = body.nodes[k].attributes['ml.max_open_jobs']; + const maxOpenJobs = +body.nodes[k].attributes['ml.max_open_jobs']; if (maxOpenJobs !== null && maxOpenJobs > 0) { count++; } diff --git a/x-pack/plugins/ml/server/lib/query_utils.ts b/x-pack/plugins/ml/server/lib/query_utils.ts index 265962bb8432c..dd4dc01498dbb 100644 --- a/x-pack/plugins/ml/server/lib/query_utils.ts +++ b/x-pack/plugins/ml/server/lib/query_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; /* * Contains utility functions for building and processing queries. */ @@ -40,7 +41,10 @@ export function buildBaseFilterCriteria( // Wraps the supplied aggregations in a sampler aggregation. // A supplied samplerShardSize (the shard_size parameter of the sampler aggregation) // of less than 1 indicates no sampling, and the aggs are returned as-is. -export function buildSamplerAggregation(aggs: object, samplerShardSize: number) { +export function buildSamplerAggregation( + aggs: any, + samplerShardSize: number +): Record { if (samplerShardSize < 1) { return aggs; } diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index f5c64cfa51efc..2d5bd1f6f6e45 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -290,11 +290,13 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { try { const { body } = await asInternalUser.search(params); + // @ts-expect-error TODO fix search response types if (body.error !== undefined && body.message !== undefined) { // No need to translate, this will not be exposed in the UI. throw new Error(`Annotations couldn't be retrieved from Elasticsearch.`); } + // @ts-expect-error TODO fix search response types const docs: Annotations = get(body, ['hits', 'hits'], []).map((d: EsResult) => { // get the original source document and the document id, we need it // to identify the annotation when editing/deleting it. diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js index 8a40787f44490..5fe783e1fc1d5 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js @@ -15,11 +15,12 @@ import { get } from 'lodash'; export function polledDataCheckerFactory({ asCurrentUser }) { class PolledDataChecker { - constructor(index, timeField, duration, query, indicesOptions) { + constructor(index, timeField, duration, query, runtimeMappings, indicesOptions) { this.index = index; this.timeField = timeField; this.duration = duration; this.query = query; + this.runtimeMappings = runtimeMappings; this.indicesOptions = indicesOptions; this.isPolled = false; @@ -62,6 +63,7 @@ export function polledDataCheckerFactory({ asCurrentUser }) { }, }, }, + ...this.runtimeMappings, }; return search; diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index 2efc2f905d9bb..1f5bbe8ac0fd4 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import numeral from '@elastic/numeral'; import { IScopedClusterClient } from 'kibana/server'; import { MLCATEGORY } from '../../../common/constants/field_types'; @@ -89,12 +89,15 @@ const cardinalityCheckProvider = (client: IScopedClusterClient) => { new Set() ); - const maxBucketFieldCardinalities: string[] = influencers.filter( + const normalizedInfluencers: estypes.Field[] = Array.isArray(influencers) + ? influencers + : [influencers]; + const maxBucketFieldCardinalities = normalizedInfluencers.filter( (influencerField) => !!influencerField && !excludedKeywords.has(influencerField) && !overallCardinalityFields.has(influencerField) - ) as string[]; + ); if (overallCardinalityFields.size > 0) { overallCardinality = await fieldsService.getCardinalityOfFields( @@ -116,7 +119,7 @@ const cardinalityCheckProvider = (client: IScopedClusterClient) => { timeFieldName, earliestMs, latestMs, - bucketSpan, + bucketSpan as string, // update to Time type datafeedConfig ); } diff --git a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts index 982485ab737ae..96bd74b9880a6 100644 --- a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts @@ -5,14 +5,17 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { difference } from 'lodash'; -import { EventManager, CalendarEvent } from './event_manager'; +import { EventManager } from './event_manager'; import type { MlClient } from '../../lib/ml_client'; +type ScheduledEvent = estypes.ScheduledEvent; + interface BasicCalendar { job_ids: string[]; description?: string; - events: CalendarEvent[]; + events: ScheduledEvent[]; } export interface Calendar extends BasicCalendar { @@ -37,23 +40,24 @@ export class CalendarManager { calendar_id: calendarId, }); - const calendars = body.calendars; + const calendars = body.calendars as Calendar[]; const calendar = calendars[0]; // Endpoint throws a 404 if calendar is not found. calendar.events = await this._eventManager.getCalendarEvents(calendarId); return calendar; } async getAllCalendars() { + // @ts-expect-error missing size argument const { body } = await this._mlClient.getCalendars({ size: 1000 }); - const events: CalendarEvent[] = await this._eventManager.getAllEvents(); - const calendars: Calendar[] = body.calendars; + const events: ScheduledEvent[] = await this._eventManager.getAllEvents(); + const calendars: Calendar[] = body.calendars as Calendar[]; calendars.forEach((cal) => (cal.events = [])); // loop events and combine with related calendars events.forEach((event) => { const calendar = calendars.find((cal) => cal.calendar_id === event.calendar_id); - if (calendar) { + if (calendar && calendar.events) { calendar.events.push(event); } }); @@ -98,7 +102,7 @@ export class CalendarManager { ); // if an event in the original calendar cannot be found, it must have been deleted - const eventsToRemove: CalendarEvent[] = origCalendar.events.filter( + const eventsToRemove: ScheduledEvent[] = origCalendar.events.filter( (event) => calendar.events.find((e) => this._eventManager.isEqual(e, event)) === undefined ); diff --git a/x-pack/plugins/ml/server/models/calendar/event_manager.ts b/x-pack/plugins/ml/server/models/calendar/event_manager.ts index 42ad3b99184c6..c870d67524135 100644 --- a/x-pack/plugins/ml/server/models/calendar/event_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/event_manager.ts @@ -5,16 +5,11 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import type { MlClient } from '../../lib/ml_client'; -export interface CalendarEvent { - calendar_id?: string; - event_id?: string; - description: string; - start_time: number; - end_time: number; -} +type ScheduledEvent = estypes.ScheduledEvent; export class EventManager { private _mlClient: MlClient; @@ -39,7 +34,7 @@ export class EventManager { return body.events; } - async addEvents(calendarId: string, events: CalendarEvent[]) { + async addEvents(calendarId: string, events: ScheduledEvent[]) { const body = { events }; return await this._mlClient.postCalendarEvents({ @@ -55,7 +50,7 @@ export class EventManager { }); } - isEqual(ev1: CalendarEvent, ev2: CalendarEvent) { + isEqual(ev1: ScheduledEvent, ev2: ScheduledEvent) { return ( ev1.event_id === ev2.event_id && ev1.description === ev2.description && diff --git a/x-pack/plugins/ml/server/models/calendar/index.ts b/x-pack/plugins/ml/server/models/calendar/index.ts index 26fb1bbe2c235..c5177dd675ca1 100644 --- a/x-pack/plugins/ml/server/models/calendar/index.ts +++ b/x-pack/plugins/ml/server/models/calendar/index.ts @@ -6,4 +6,3 @@ */ export { CalendarManager, Calendar, FormCalendar } from './calendar_manager'; -export { CalendarEvent } from './event_manager'; diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts index 0cbbf67dbbfac..516823ff78758 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts @@ -79,9 +79,9 @@ export function analyticsAuditMessagesProvider({ asInternalUser }: IScopedCluste }, }); - let messages = []; - if (body.hits.total.value > 0) { - messages = body.hits.hits.map((hit: Message) => hit._source); + let messages: JobMessage[] = []; + if (typeof body.hits.total !== 'number' && body.hits.total.value > 0) { + messages = (body.hits.hits as Message[]).map((hit) => hit._source); messages.reverse(); } return messages; diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts index 371446638814a..4c79855f39e89 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import type { estypes } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'kibana/server'; import { getAnalysisType } from '../../../common/util/analytics_utils'; import { @@ -197,7 +198,7 @@ async function getValidationCheckMessages( analyzedFields: string[], index: string | string[], analysisConfig: AnalysisConfig, - query: unknown = defaultQuery + query: estypes.QueryContainer = defaultQuery ) { const analysisType = getAnalysisType(analysisConfig); const depVar = getDependentVar(analysisConfig); @@ -241,9 +242,11 @@ async function getValidationCheckMessages( }, }); + // @ts-expect-error const totalDocs = body.hits.total.value; if (body.aggregations) { + // @ts-expect-error Object.entries(body.aggregations).forEach(([aggName, { doc_count: docCount, value }]) => { const empty = docCount / totalDocs; diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 4e99330610fca..21ed258a0b764 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -288,6 +288,7 @@ export class DataRecognizer { body: searchBody, }); + // @ts-expect-error fix search response return body.hits.total.value > 0; } @@ -864,10 +865,10 @@ export class DataRecognizer { try { const duration: { start: string; end?: string } = { start: '0' }; if (start !== undefined) { - duration.start = (start as unknown) as string; + duration.start = String(start); } if (end !== undefined) { - duration.end = (end as unknown) as string; + duration.end = String(end); } const { diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index 4db8295d93997..679b7b3f12a23 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -625,15 +625,13 @@ export class DataVisualizer { cardinalityField = aggs[`${safeFieldName}_cardinality`] = { cardinality: { script: datafeedConfig?.script_fields[field].script }, }; - } else if (datafeedConfig?.runtime_mappings?.hasOwnProperty(field)) { - cardinalityField = { - cardinality: { field }, - }; - runtimeMappings.runtime_mappings = datafeedConfig.runtime_mappings; } else { cardinalityField = { cardinality: { field }, }; + if (datafeedConfig !== undefined && isPopulatedObject(datafeedConfig?.runtime_mappings)) { + runtimeMappings.runtime_mappings = datafeedConfig.runtime_mappings; + } } aggs[`${safeFieldName}_cardinality`] = cardinalityField; }); @@ -656,6 +654,7 @@ export class DataVisualizer { }); const aggregations = body.aggregations; + // @ts-expect-error fix search response const totalCount = body.hits.total.value; const stats = { totalCount, @@ -741,6 +740,7 @@ export class DataVisualizer { size, body: searchBody, }); + // @ts-expect-error fix search response return body.hits.total.value > 0; } @@ -1215,6 +1215,7 @@ export class DataVisualizer { fieldName: field, examples: [] as any[], }; + // @ts-expect-error fix search response if (body.hits.total.value > 0) { const hits = body.hits.hits; for (let i = 0; i < hits.length; i++) { diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index 1270cc6f08e23..c2b95d9a58584 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -14,6 +14,7 @@ import { AggCardinality } from '../../../common/types/fields'; import { isValidAggregationField } from '../../../common/util/validation_utils'; import { getDatafeedAggregations } from '../../../common/util/datafeed_utils'; import { Datafeed, IndicesOptions } from '../../../common/types/anomaly_detection_jobs'; +import { RuntimeMappings } from '../../../common/types/fields'; /** * Service for carrying out queries to obtain data @@ -183,6 +184,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } = await asCurrentUser.search({ index, body, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options ...(datafeedConfig?.indices_options ?? {}), }); @@ -191,6 +193,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } const aggResult = fieldsToAgg.reduce((obj, field) => { + // @ts-expect-error fix search aggregation response obj[field] = (aggregations[field] || { value: 0 }).value; return obj; }, {} as { [field: string]: number }); @@ -212,6 +215,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { index: string[] | string, timeFieldName: string, query: any, + runtimeMappings?: RuntimeMappings, indicesOptions?: IndicesOptions ): Promise<{ success: boolean; @@ -239,15 +243,20 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, ...(indicesOptions ?? {}), }); if (aggregations && aggregations.earliest && aggregations.latest) { + // @ts-expect-error fix search aggregation response obj.start.epoch = aggregations.earliest.value; + // @ts-expect-error fix search aggregation response obj.start.string = aggregations.earliest.value_as_string; + // @ts-expect-error fix search aggregation response obj.end.epoch = aggregations.latest.value; + // @ts-expect-error fix search aggregation response obj.end.string = aggregations.latest.value_as_string; } return obj; @@ -397,6 +406,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } = await asCurrentUser.search({ index, body, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options ...(datafeedConfig?.indices_options ?? {}), }); @@ -405,6 +415,7 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { } const aggResult = fieldsToAgg.reduce((obj, field) => { + // @ts-expect-error fix search aggregation response obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; return obj; }, {} as { [field: string]: number }); diff --git a/x-pack/plugins/ml/server/models/filter/filter_manager.ts b/x-pack/plugins/ml/server/models/filter/filter_manager.ts index c0c8b53e7aac6..2a25087260795 100644 --- a/x-pack/plugins/ml/server/models/filter/filter_manager.ts +++ b/x-pack/plugins/ml/server/models/filter/filter_manager.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import type { MlClient } from '../../lib/ml_client'; -import { DetectorRule, DetectorRuleScope } from '../../../common/types/detector_rules'; +// import { DetectorRule, DetectorRuleScope } from '../../../common/types/detector_rules'; +import { Job } from '../../../common/types/anomaly_detection_jobs'; export interface Filter { filter_id: string; @@ -46,17 +48,17 @@ interface FiltersInUse { [id: string]: FilterUsage; } -interface PartialDetector { - detector_description: string; - custom_rules: DetectorRule[]; -} +// interface PartialDetector { +// detector_description: string; +// custom_rules: DetectorRule[]; +// } -interface PartialJob { - job_id: string; - analysis_config: { - detectors: PartialDetector[]; - }; -} +// interface PartialJob { +// job_id: string; +// analysis_config: { +// detectors: PartialDetector[]; +// }; +// } export class FilterManager { constructor(private _mlClient: MlClient) {} @@ -69,15 +71,23 @@ export class FilterManager { this._mlClient.getFilters({ filter_id: filterId }), ]); - if (results[FILTERS] && results[FILTERS].body.filters.length) { + if ( + results[FILTERS] && + (results[FILTERS].body as estypes.GetFiltersResponse).filters.length + ) { let filtersInUse: FiltersInUse = {}; - if (results[JOBS] && results[JOBS].body.jobs) { - filtersInUse = this.buildFiltersInUse(results[JOBS].body.jobs); + if (results[JOBS] && (results[JOBS].body as estypes.GetJobsResponse).jobs) { + filtersInUse = this.buildFiltersInUse( + (results[JOBS].body as estypes.GetJobsResponse).jobs + ); } - const filter = results[FILTERS].body.filters[0]; - filter.used_by = filtersInUse[filter.filter_id]; - return filter; + const filter = (results[FILTERS].body as estypes.GetFiltersResponse).filters[0]; + return { + ...filter, + used_by: filtersInUse[filter.filter_id], + item_count: 0, + } as FilterStats; } else { throw Boom.notFound(`Filter with the id "${filterId}" not found`); } @@ -105,8 +115,8 @@ export class FilterManager { // Build a map of filter_ids against jobs and detectors using that filter. let filtersInUse: FiltersInUse = {}; - if (results[JOBS] && results[JOBS].body.jobs) { - filtersInUse = this.buildFiltersInUse(results[JOBS].body.jobs); + if (results[JOBS] && (results[JOBS].body as estypes.GetJobsResponse).jobs) { + filtersInUse = this.buildFiltersInUse((results[JOBS].body as estypes.GetJobsResponse).jobs); } // For each filter, return just @@ -115,8 +125,8 @@ export class FilterManager { // item_count // jobs using the filter const filterStats: FilterStats[] = []; - if (results[FILTERS] && results[FILTERS].body.filters) { - results[FILTERS].body.filters.forEach((filter: Filter) => { + if (results[FILTERS] && (results[FILTERS].body as estypes.GetFiltersResponse).filters) { + (results[FILTERS].body as estypes.GetFiltersResponse).filters.forEach((filter: Filter) => { const stats: FilterStats = { filter_id: filter.filter_id, description: filter.description, @@ -173,7 +183,7 @@ export class FilterManager { return body; } - buildFiltersInUse(jobsList: PartialJob[]) { + buildFiltersInUse(jobsList: Job[]) { // Build a map of filter_ids against jobs and detectors using that filter. const filtersInUse: FiltersInUse = {}; jobsList.forEach((job) => { @@ -183,7 +193,7 @@ export class FilterManager { const rules = detector.custom_rules; rules.forEach((rule) => { if (rule.scope) { - const ruleScope: DetectorRuleScope = rule.scope; + const ruleScope = rule.scope; const scopeFields = Object.keys(ruleScope); scopeFields.forEach((scopeField) => { const filter = ruleScope[scopeField]; diff --git a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts index cb651a0a410af..8279571adbae2 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { IScopedClusterClient } from 'kibana/server'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; @@ -27,7 +28,8 @@ export interface MlDatafeedsStatsResponse { interface Results { [id: string]: { - started: boolean; + started?: estypes.StartDatafeedResponse['started']; + stopped?: estypes.StopDatafeedResponse['stopped']; error?: any; }; } @@ -105,8 +107,10 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie async function startDatafeed(datafeedId: string, start?: number, end?: number) { return mlClient.startDatafeed({ datafeed_id: datafeedId, - start: (start as unknown) as string, - end: (end as unknown) as string, + body: { + start: start !== undefined ? String(start) : undefined, + end: end !== undefined ? String(end) : undefined, + }, }); } @@ -115,18 +119,16 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie for (const datafeedId of datafeedIds) { try { - const { body } = await mlClient.stopDatafeed<{ - started: boolean; - }>({ + const { body } = await mlClient.stopDatafeed({ datafeed_id: datafeedId, }); - results[datafeedId] = body; + results[datafeedId] = { stopped: body.stopped }; } catch (error) { if (isRequestTimeout(error)) { return fillResultsWithTimeouts(results, datafeedId, datafeedIds, DATAFEED_STATE.STOPPED); } else { results[datafeedId] = { - started: false, + stopped: false, error: error.body, }; } @@ -175,9 +177,7 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie // get all the datafeeds and match it with the jobId const { body: { datafeeds }, - } = await mlClient.getDatafeeds( - excludeGenerated ? { exclude_generated: true } : {} - ); + } = await mlClient.getDatafeeds(excludeGenerated ? { exclude_generated: true } : {}); // for (const result of datafeeds) { if (result.job_id === jobId) { return result; @@ -190,7 +190,7 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie try { const { body: { datafeeds: datafeedsResults }, - } = await mlClient.getDatafeeds({ + } = await mlClient.getDatafeeds({ datafeed_id: assumedDefaultDatafeedId, ...(excludeGenerated ? { exclude_generated: true } : {}), }); @@ -219,6 +219,8 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie datafeed.indices, job.data_description.time_field, query, + datafeed.runtime_mappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options datafeed.indices_options ); @@ -350,6 +352,7 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie const data = { index: datafeed.indices, body, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options ...(datafeed.indices_options ?? {}), }; diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index ac3e00a918da8..d0d824a88f5a9 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -143,7 +143,10 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { throw Boom.notFound(`Cannot find datafeed for job ${jobId}`); } - const { body } = await mlClient.stopDatafeed({ datafeed_id: datafeedId, force: true }); + const { body } = await mlClient.stopDatafeed({ + datafeed_id: datafeedId, + body: { force: true }, + }); if (body.stopped !== true) { return { success: false }; } @@ -316,6 +319,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { (ds) => ds.datafeed_id === datafeed.datafeed_id ); if (datafeedStats) { + // @ts-expect-error datafeeds[datafeed.job_id] = { ...datafeed, ...datafeedStats }; } } @@ -384,6 +388,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { if (jobStatsResults && jobStatsResults.jobs) { const jobStats = jobStatsResults.jobs.find((js) => js.job_id === tempJob.job_id); if (jobStats !== undefined) { + // @ts-expect-error tempJob = { ...tempJob, ...jobStats }; if (jobStats.node) { tempJob.node = jobStats.node; @@ -417,13 +422,20 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { const detailed = true; const jobIds: string[] = []; try { - const { body } = await asInternalUser.tasks.list({ actions, detailed }); - Object.keys(body.nodes).forEach((nodeId) => { - const tasks = body.nodes[nodeId].tasks; - Object.keys(tasks).forEach((taskId) => { - jobIds.push(tasks[taskId].description.replace(/^delete-job-/, '')); - }); + const { body } = await asInternalUser.tasks.list({ + // @ts-expect-error @elastic-elasticsearch expects it to be a string + actions, + detailed, }); + + if (body.nodes) { + Object.keys(body.nodes).forEach((nodeId) => { + const tasks = body.nodes![nodeId].tasks; + Object.keys(tasks).forEach((taskId) => { + jobIds.push(tasks[taskId].description!.replace(/^delete-job-/, '')); + }); + }); + } } catch (e) { // if the user doesn't have permission to load the task list, // use the jobs list to get the ids of deleting jobs diff --git a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts index f1f5d98b96a53..425dff89032a3 100644 --- a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts +++ b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts @@ -73,8 +73,9 @@ export function modelSnapshotProvider(client: IScopedClusterClient, mlClient: Ml if (replay && model.snapshot_id === snapshotId && snapshot.model_snapshots.length) { // create calendar before starting restarting the datafeed if (calendarEvents !== undefined && calendarEvents.length) { + const calendarId = String(Date.now()); const calendar: FormCalendar = { - calendarId: String(Date.now()), + calendarId, job_ids: [jobId], description: i18n.translate( 'xpack.ml.models.jobService.revertModelSnapshot.autoCreatedCalendar.description', @@ -83,16 +84,18 @@ export function modelSnapshotProvider(client: IScopedClusterClient, mlClient: Ml } ), events: calendarEvents.map((s) => ({ + calendar_id: calendarId, + event_id: '', description: s.description, - start_time: s.start, - end_time: s.end, + start_time: `${s.start}`, + end_time: `${s.end}`, })), }; const cm = new CalendarManager(mlClient); await cm.newCalendar(calendar); } - forceStartDatafeeds([datafeedId], snapshot.model_snapshots[0].latest_record_time_stamp, end); + forceStartDatafeeds([datafeedId], +snapshot.model_snapshots[0].latest_record_time_stamp, end); } return { success: true }; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 37fa675362773..b0ee20763f430 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -127,7 +127,7 @@ export function categorizationExamplesProvider({ async function loadTokens(examples: string[], analyzer: CategorizationAnalyzer) { const { body: { tokens }, - } = await asInternalUser.indices.analyze<{ tokens: Token[] }>({ + } = await asInternalUser.indices.analyze({ body: { ...getAnalyzer(analyzer), text: examples, @@ -139,19 +139,21 @@ export function categorizationExamplesProvider({ const tokensPerExample: Token[][] = examples.map((e) => []); - tokens.forEach((t, i) => { - for (let g = 0; g < sumLengths.length; g++) { - if (t.start_offset <= sumLengths[g] + g) { - const offset = g > 0 ? sumLengths[g - 1] + g : 0; - tokensPerExample[g].push({ - ...t, - start_offset: t.start_offset - offset, - end_offset: t.end_offset - offset, - }); - break; + if (tokens !== undefined) { + tokens.forEach((t, i) => { + for (let g = 0; g < sumLengths.length; g++) { + if (t.start_offset <= sumLengths[g] + g) { + const offset = g > 0 ? sumLengths[g - 1] + g : 0; + tokensPerExample[g].push({ + ...t, + start_offset: t.start_offset - offset, + end_offset: t.end_offset - offset, + }); + break; + } } - } - }); + }); + } return tokensPerExample; } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts index 6637faeba094d..851336056a7f5 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts @@ -81,6 +81,7 @@ export function topCategoriesProvider(mlClient: MlClient) { const catCounts: Array<{ id: CategoryId; count: number; + // @ts-expect-error }> = body.aggregations?.cat_count?.buckets.map((c: any) => ({ id: c.key, count: c.doc_count, @@ -125,6 +126,7 @@ export function topCategoriesProvider(mlClient: MlClient) { [] ); + // @ts-expect-error return body.hits.hits?.map((c: { _source: Category }) => c._source) || []; } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 7ce54cd2f9c5e..0287c2af11a7e 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -5,13 +5,14 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'kibana/server'; import { cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/server'; import { Field, FieldId, NewJobCaps, RollupFields } from '../../../../common/types/fields'; import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; import { combineFieldsAndAggs } from '../../../../common/util/fields_utils'; -import { rollupServiceProvider, RollupJob } from './rollup'; +import { rollupServiceProvider } from './rollup'; import { aggregations, mlOnlyAggregations } from '../../../../common/constants/aggregation_types'; const supportedTypes: string[] = [ @@ -109,7 +110,9 @@ class FieldsService { this._mlClusterClient, this._savedObjectsClient ); - const rollupConfigs: RollupJob[] | null = await rollupService.getRollupJobs(); + const rollupConfigs: + | estypes.RollupCapabilitiesJob[] + | null = await rollupService.getRollupJobs(); // if a rollup index has been specified, yet there are no // rollup configs, return with no results @@ -131,14 +134,16 @@ class FieldsService { } } -function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { +function combineAllRollupFields(rollupConfigs: estypes.RollupCapabilitiesJob[]): RollupFields { const rollupFields: RollupFields = {}; rollupConfigs.forEach((conf) => { Object.keys(conf.fields).forEach((fieldName) => { if (rollupFields[fieldName] === undefined) { + // @ts-expect-error fix type. our RollupFields type is better rollupFields[fieldName] = conf.fields[fieldName]; } else { const aggs = conf.fields[fieldName]; + // @ts-expect-error fix type. our RollupFields type is better aggs.forEach((agg) => { if (rollupFields[fieldName].find((f) => f.agg === agg.agg) === null) { rollupFields[fieldName].push(agg); diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index 3b480bae2199e..d83f7afb4cdf6 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'kibana/server'; import { SavedObject } from 'kibana/server'; import { IndexPatternAttributes } from 'src/plugins/data/server'; @@ -26,7 +27,7 @@ export async function rollupServiceProvider( const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, savedObjectsClient); let jobIndexPatterns: string[] = [indexPattern]; - async function getRollupJobs(): Promise { + async function getRollupJobs(): Promise { if (rollupIndexPatternObject !== null) { const parsedTypeMetaData = JSON.parse(rollupIndexPatternObject.attributes.typeMeta); const rollUpIndex: string = parsedTypeMetaData.params.rollup_index; @@ -36,7 +37,7 @@ export async function rollupServiceProvider( const indexRollupCaps = rollupCaps[rollUpIndex]; if (indexRollupCaps && indexRollupCaps.rollup_jobs) { - jobIndexPatterns = indexRollupCaps.rollup_jobs.map((j: RollupJob) => j.index_pattern); + jobIndexPatterns = indexRollupCaps.rollup_jobs.map((j) => j.index_pattern); return indexRollupCaps.rollup_jobs; } diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts index 31d98753f0bd1..94e9a8dc7bffb 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts @@ -64,7 +64,14 @@ export async function validateJob( const fs = fieldsServiceProvider(client); const index = job.datafeed_config.indices.join(','); const timeField = job.data_description.time_field; - const timeRange = await fs.getTimeFieldRange(index, timeField, job.datafeed_config.query); + const timeRange = await fs.getTimeFieldRange( + index, + timeField, + job.datafeed_config.query, + job.datafeed_config.runtime_mappings, + // @ts-expect-error @elastic/elasticsearch Datafeed is missing indices_options + job.datafeed_config.indices_options + ); duration = { start: timeRange.start.epoch, diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index 853f96ad77743..44c5e3cabb18f 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -148,8 +148,7 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit', () => { const job = getJobConfig(); const duration = undefined; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '31mb'; + job.analysis_limits!.model_memory_limit = '31mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -166,8 +165,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(10); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '20mb'; + job.analysis_limits!.model_memory_limit = '20mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -184,8 +182,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '30mb'; + job.analysis_limits!.model_memory_limit = '30mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -202,8 +199,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '10mb'; + job.analysis_limits!.model_memory_limit = '10mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -222,8 +218,7 @@ describe('ML - validateModelMemoryLimit', () => { const duration = { start: 0, end: 1 }; // @ts-expect-error delete mlInfoResponse.limits.max_model_memory_limit; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '10mb'; + job.analysis_limits!.model_memory_limit = '10mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -239,8 +234,7 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit, no max setting', () => { const job = getJobConfig(); const duration = undefined; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '31mb'; + job.analysis_limits!.model_memory_limit = '31mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -256,8 +250,7 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit, no max setting, above effective max mml', () => { const job = getJobConfig(); const duration = undefined; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '41mb'; + job.analysis_limits!.model_memory_limit = '41mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -274,8 +267,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '20mb'; + job.analysis_limits!.model_memory_limit = '20mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -292,8 +284,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '0mb'; + job.analysis_limits!.model_memory_limit = '0mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -310,8 +301,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '10mbananas'; + job.analysis_limits!.model_memory_limit = '10mbananas'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -328,8 +318,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '10'; + job.analysis_limits!.model_memory_limit = '10'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -346,8 +335,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = 'mb'; + job.analysis_limits!.model_memory_limit = 'mb'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -364,8 +352,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = 'asdf'; + job.analysis_limits!.model_memory_limit = 'asdf'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -382,8 +369,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '1023KB'; + job.analysis_limits!.model_memory_limit = '1023KB'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -400,8 +386,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '1024KB'; + job.analysis_limits!.model_memory_limit = '1024KB'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -418,8 +403,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '6MB'; + job.analysis_limits!.model_memory_limit = '6MB'; return validateModelMemoryLimit( getMockMlClusterClient(), @@ -436,8 +420,7 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-expect-error - job.analysis_limits.model_memory_limit = '20MB'; + job.analysis_limits!.model_memory_limit = '20MB'; return validateModelMemoryLimit( getMockMlClusterClient(), diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index 81e26808bd246..be1786d64f2aa 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -10,7 +10,6 @@ import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; import { PartitionFieldsType } from '../../../common/types/anomalies'; import { CriteriaField } from './results_service'; import { FieldConfig, FieldsConfig } from '../../routes/schemas/results_service_schema'; -import { Job } from '../../../common/types/anomaly_detection_jobs'; import type { MlClient } from '../../lib/ml_client'; type SearchTerm = @@ -151,7 +150,7 @@ export const getPartitionFieldsValuesFactory = (mlClient: MlClient) => throw Boom.notFound(`Job with the id "${jobId}" not found`); } - const job: Job = jobsResponse.jobs[0]; + const job = jobsResponse.jobs[0]; const isModelPlotEnabled = job?.model_plot_config?.enabled; const isAnomalousOnly = (Object.entries(fieldsConfig) as Array<[string, FieldConfig]>).some( diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 3d1db66cc24cc..1996acd2cdb06 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -183,6 +183,7 @@ export function resultsServiceProvider(mlClient: MlClient) { anomalies: [], interval: 'second', }; + // @ts-expect-error update to correct search response if (body.hits.total.value > 0) { let records: AnomalyRecordDoc[] = []; body.hits.hits.forEach((hit: any) => { @@ -401,6 +402,7 @@ export function resultsServiceProvider(mlClient: MlClient) { ); const examplesByCategoryId: { [key: string]: any } = {}; + // @ts-expect-error update to correct search response if (body.hits.total.value > 0) { body.hits.hits.forEach((hit: any) => { if (maxExamples) { @@ -437,6 +439,7 @@ export function resultsServiceProvider(mlClient: MlClient) { ); const definition = { categoryId, terms: null, regex: null, examples: [] }; + // @ts-expect-error update to correct search response if (body.hits.total.value > 0) { const source = body.hits.hits[0]._source; definition.categoryId = source.category_id; @@ -576,6 +579,7 @@ export function resultsServiceProvider(mlClient: MlClient) { ); if (fieldToBucket === JOB_ID) { finalResults = { + // @ts-expect-error update search response jobs: results.aggregations?.unique_terms?.buckets.map( (b: { key: string; doc_count: number }) => b.key ), @@ -588,6 +592,7 @@ export function resultsServiceProvider(mlClient: MlClient) { }, {} ); + // @ts-expect-error update search response results.aggregations.jobs.buckets.forEach( (bucket: { key: string | number; unique_stopped_partitions: { buckets: any[] } }) => { jobs[bucket.key] = bucket.unique_stopped_partitions.buckets.map((b) => b.key); diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index aa7adbe2db660..ed583bd9e3eb1 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { schema } from '@kbn/config-schema'; -import { RequestParams } from '@elastic/elasticsearch'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -23,8 +23,6 @@ import { updateModelSnapshotSchema, } from './schemas/anomaly_detectors_schema'; -import { Job, JobStats } from '../../common/types/anomaly_detection_jobs'; - /** * Routes for the anomaly detectors */ @@ -49,7 +47,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, response }) => { try { - const { body } = await mlClient.getJobs<{ jobs: Job[] }>(); + const { body } = await mlClient.getJobs(); return response.ok({ body, }); @@ -81,7 +79,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { try { const { jobId } = request.params; - const { body } = await mlClient.getJobs<{ jobs: Job[] }>({ job_id: jobId }); + const { body } = await mlClient.getJobs({ job_id: jobId }); return response.ok({ body, }); @@ -111,7 +109,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, response }) => { try { - const { body } = await mlClient.getJobStats<{ jobs: JobStats[] }>(); + const { body } = await mlClient.getJobStats(); return response.ok({ body, }); @@ -283,7 +281,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { try { - const options: RequestParams.MlCloseJob = { + const options: estypes.CloseJobRequest = { job_id: request.params.jobId, }; const force = request.query.force; @@ -321,7 +319,7 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { try { - const options: RequestParams.MlDeleteJob = { + const options: estypes.DeleteJobRequest = { job_id: request.params.jobId, wait_for_completion: false, }; @@ -395,7 +393,9 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { const duration = request.body.duration; const { body } = await mlClient.forecast({ job_id: jobId, - duration, + body: { + duration, + }, }); return response.ok({ body, @@ -513,10 +513,12 @@ export function jobRoutes({ router, routeGuard }: RouteInitialization) { try { const { body } = await mlClient.getOverallBuckets({ job_id: request.params.jobId, - top_n: request.body.topN, - bucket_span: request.body.bucketSpan, - start: request.body.start, - end: request.body.end, + body: { + top_n: request.body.topN, + bucket_span: request.body.bucketSpan, + start: request.body.start !== undefined ? String(request.body.start) : undefined, + end: request.body.end !== undefined ? String(request.body.end) : undefined, + }, }); return response.ok({ body, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 1a10046380658..ba61a987d69ef 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -1,6 +1,6 @@ { "name": "ml_kibana_api", - "version": "7.11.0", + "version": "7.13.0", "description": "This is the documentation of the REST API provided by the Machine Learning Kibana plugin. Each API is experimental and can include breaking changes in any version.", "title": "ML Kibana API", "order": [ @@ -159,6 +159,9 @@ "GetTrainedModel", "GetTrainedModelStats", "GetTrainedModelPipelines", - "DeleteTrainedModel" + "DeleteTrainedModel", + + "Alerting", + "PreviewAlert" ] } diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts index 3a30a141f88f5..f94aaffc41a7b 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import * as ts from 'typescript'; export interface DocEntry { diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts index ad00915f28d6d..430f105fb27d4 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/version_filter.ts @@ -7,7 +7,7 @@ import { Block } from './types'; -const API_VERSION = '7.8.0'; +const API_VERSION = '7.13.0'; /** * Post Filter parsed results. diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 448d798845e18..520f8ce6fb0a9 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -28,7 +28,6 @@ import { AnalyticsManager } from '../models/data_frame_analytics/analytics_manag import { validateAnalyticsJob } from '../models/data_frame_analytics/validation'; import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics'; import { getAuthorizationHeader } from '../lib/request_authorization'; -import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; import type { MlClient } from '../lib/ml_client'; function getIndexPatternId(context: RequestHandlerContext, patternName: string) { @@ -603,14 +602,10 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout for (const id of analyticsIds) { try { const { body } = allSpaces - ? await client.asInternalUser.ml.getDataFrameAnalytics<{ - data_frame_analytics: DataFrameAnalyticsConfig[]; - }>({ + ? await client.asInternalUser.ml.getDataFrameAnalytics({ id, }) - : await mlClient.getDataFrameAnalytics<{ - data_frame_analytics: DataFrameAnalyticsConfig[]; - }>({ + : await mlClient.getDataFrameAnalytics({ id, }); results[id] = body.data_frame_analytics.length > 0; diff --git a/x-pack/plugins/ml/server/routes/datafeeds.ts b/x-pack/plugins/ml/server/routes/datafeeds.ts index 90d90a0e2b1e4..2013af3ee8735 100644 --- a/x-pack/plugins/ml/server/routes/datafeeds.ts +++ b/x-pack/plugins/ml/server/routes/datafeeds.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { RequestParams } from '@elastic/elasticsearch'; +import { estypes } from '@elastic/elasticsearch'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -16,8 +16,6 @@ import { } from './schemas/datafeeds_schema'; import { getAuthorizationHeader } from '../lib/request_authorization'; -import { Datafeed, DatafeedStats } from '../../common/types/anomaly_detection_jobs'; - /** * Routes for datafeed service */ @@ -39,7 +37,7 @@ export function dataFeedRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, response }) => { try { - const { body } = await mlClient.getDatafeeds<{ datafeeds: Datafeed[] }>(); + const { body } = await mlClient.getDatafeeds(); return response.ok({ body, }); @@ -99,9 +97,7 @@ export function dataFeedRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, response }) => { try { - const { body } = await mlClient.getDatafeedStats<{ - datafeeds: DatafeedStats[]; - }>(); + const { body } = await mlClient.getDatafeedStats(); return response.ok({ body, }); @@ -251,7 +247,7 @@ export function dataFeedRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { try { - const options: RequestParams.MlDeleteDatafeed = { + const options: estypes.DeleteDatafeedRequest = { datafeed_id: request.params.datafeedId, }; const force = request.query.force; @@ -298,8 +294,10 @@ export function dataFeedRoutes({ router, routeGuard }: RouteInitialization) { const { body } = await mlClient.startDatafeed({ datafeed_id: datafeedId, - start, - end, + body: { + start: start !== undefined ? String(start) : undefined, + end: end !== undefined ? String(end) : undefined, + }, }); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/fields_service.ts b/x-pack/plugins/ml/server/routes/fields_service.ts index c087b86172fa9..dc43d915e87a2 100644 --- a/x-pack/plugins/ml/server/routes/fields_service.ts +++ b/x-pack/plugins/ml/server/routes/fields_service.ts @@ -22,8 +22,8 @@ function getCardinalityOfFields(client: IScopedClusterClient, payload: any) { function getTimeFieldRange(client: IScopedClusterClient, payload: any) { const fs = fieldsServiceProvider(client); - const { index, timeFieldName, query, indicesOptions } = payload; - return fs.getTimeFieldRange(index, timeFieldName, query, indicesOptions); + const { index, timeFieldName, query, runtimeMappings, indicesOptions } = payload; + return fs.getTimeFieldRange(index, timeFieldName, query, runtimeMappings, indicesOptions); } /** diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index b3aa9f956895a..1f755c27db871 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -791,7 +791,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { body: datafeedPreviewSchema, }, options: { - tags: ['access:ml:canGetJobs'], + tags: ['access:ml:canPreviewDatafeed'], }, }, routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { diff --git a/x-pack/plugins/ml/server/routes/schemas/fields_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/fields_service_schema.ts index db827b26fe73a..76a307e710dc8 100644 --- a/x-pack/plugins/ml/server/routes/schemas/fields_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/fields_service_schema.ts @@ -31,5 +31,6 @@ export const getTimeFieldRangeSchema = schema.object({ /** Query to match documents in the index(es). */ query: schema.maybe(schema.any()), /** Additional search options. */ + runtimeMappings: schema.maybe(schema.any()), indicesOptions: indicesOptionsSchema, }); diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index bfa7137b3e6d6..dbfc2195a12e1 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -13,7 +13,6 @@ import { optionalModelIdSchema, } from './schemas/inference_schema'; import { modelsProvider } from '../models/data_frame_analytics'; -import { InferenceConfigResponse } from '../../common/types/trained_models'; export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) { /** @@ -38,7 +37,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) try { const { modelId } = request.params; const { with_pipelines: withPipelines, ...query } = request.query; - const { body } = await mlClient.getTrainedModels({ + const { body } = await mlClient.getTrainedModels({ size: 1000, ...query, ...(modelId ? { model_id: modelId } : {}), @@ -85,7 +84,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) tags: ['access:ml:canGetDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { try { const { modelId } = request.params; const { body } = await mlClient.getTrainedModelsStats({ diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 04d5e53a5568b..6b24ef000b695 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -10,8 +10,6 @@ import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; import type { JobSavedObjectService } from './service'; import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; -import { Job } from '../../common/types/anomaly_detection_jobs'; -import { Datafeed } from '../../common/types/anomaly_detection_jobs'; import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; import { ResolveMlCapabilities } from '../../common/types/capabilities'; @@ -51,13 +49,13 @@ export function checksFactory( const jobObjects = await jobSavedObjectService.getAllJobObjects(undefined, false); // load all non-space jobs and datafeeds - const { body: adJobs } = await client.asInternalUser.ml.getJobs<{ jobs: Job[] }>(); - const { body: datafeeds } = await client.asInternalUser.ml.getDatafeeds<{ - datafeeds: Datafeed[]; - }>(); - const { body: dfaJobs } = await client.asInternalUser.ml.getDataFrameAnalytics<{ - data_frame_analytics: DataFrameAnalyticsConfig[]; - }>(); + const { body: adJobs } = await client.asInternalUser.ml.getJobs(); + const { body: datafeeds } = await client.asInternalUser.ml.getDatafeeds(); + const { + body: dfaJobs, + } = ((await client.asInternalUser.ml.getDataFrameAnalytics()) as unknown) as { + body: { data_frame_analytics: DataFrameAnalyticsConfig[] }; + }; const savedObjectsStatus: JobSavedObjectStatus[] = jobObjects.map( ({ attributes, namespaces }) => { diff --git a/x-pack/plugins/ml/server/shared_services/providers/system.ts b/x-pack/plugins/ml/server/shared_services/providers/system.ts index 021bf5b12625b..1e3dcd7de5240 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/system.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/system.ts @@ -7,7 +7,6 @@ import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; -import { RequestParams } from '@elastic/elasticsearch'; import { MlLicense } from '../../../common/license'; import { CloudSetup } from '../../../../cloud/server'; import { spacesUtilsProvider } from '../../lib/spaces_utils'; @@ -24,10 +23,7 @@ export interface MlSystemProvider { ): { mlCapabilities(): Promise; mlInfo(): Promise; - mlAnomalySearch( - searchParams: RequestParams.Search, - jobIds: string[] - ): Promise>; + mlAnomalySearch(searchParams: any, jobIds: string[]): Promise>; }; } @@ -73,10 +69,7 @@ export function getMlSystemProvider( }; }); }, - async mlAnomalySearch( - searchParams: RequestParams.Search, - jobIds: string[] - ): Promise> { + async mlAnomalySearch(searchParams: any, jobIds: string[]): Promise> { return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canAccessML']) diff --git a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js index d4b8ea4a76e43..12cfc4f132863 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js +++ b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js @@ -41,17 +41,14 @@ const IsClusterSupported = ({ isSupported, children }) => { * completely */ const IsAlertsSupported = (props) => { - const { alertsMeta = { enabled: true }, clusterMeta = { enabled: true } } = props.cluster.alerts; - if (alertsMeta.enabled && clusterMeta.enabled) { + const { alertsMeta = { enabled: true } } = props.cluster.alerts; + if (alertsMeta.enabled) { return {props.children}; } - const message = - alertsMeta.message || - clusterMeta.message || - i18n.translate('xpack.monitoring.cluster.listing.unknownHealthMessage', { - defaultMessage: 'Unknown', - }); + const message = i18n.translate('xpack.monitoring.cluster.listing.unknownHealthMessage', { + defaultMessage: 'Unknown', + }); return ( diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/__fixtures__/create_stubs.js b/x-pack/plugins/monitoring/server/cluster_alerts/__fixtures__/create_stubs.js deleted file mode 100644 index cf8aba8ca7008..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/__fixtures__/create_stubs.js +++ /dev/null @@ -1,34 +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 sinon from 'sinon'; - -export function createStubs(mockQueryResult, featureStub) { - const callWithRequestStub = sinon.stub().returns(Promise.resolve(mockQueryResult)); - const getClusterStub = sinon.stub().returns({ callWithRequest: callWithRequestStub }); - const configStub = sinon.stub().returns({ - get: sinon.stub().withArgs('xpack.monitoring.cluster_alerts.enabled').returns(true), - }); - return { - callWithRequestStub, - mockReq: { - server: { - config: configStub, - plugins: { - monitoring: { - info: { - feature: featureStub, - }, - }, - elasticsearch: { - getCluster: getClusterStub, - }, - }, - }, - }, - }; -} diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.js b/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.js deleted file mode 100644 index 05f0524c12521..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.js +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import moment from 'moment'; -import { verifyMonitoringLicense } from './verify_monitoring_license'; -import { i18n } from '@kbn/i18n'; - -/** - * Retrieve any statically defined cluster alerts (not indexed) for the {@code cluster}. - * - * In the future, if we add other static cluster alerts, then we should probably just return an array. - * It may also make sense to put this into its own file in the future. - * - * @param {Object} cluster The cluster object containing the cluster's license. - * @return {Object} The alert to use for the cluster. {@code null} if none. - */ -export function staticAlertForCluster(cluster) { - const clusterNeedsTLSEnabled = get(cluster, 'license.cluster_needs_tls', false); - - if (clusterNeedsTLSEnabled) { - const versionParts = get(cluster, 'version', '').split('.'); - const version = versionParts.length > 1 ? `${versionParts[0]}.${versionParts[1]}` : 'current'; - - return { - metadata: { - severity: 0, - cluster_uuid: cluster.cluster_uuid, - link: `https://www.elastic.co/guide/en/x-pack/${version}/ssl-tls.html`, - }, - update_timestamp: cluster.timestamp, - timestamp: get(cluster, 'license.issue_date', cluster.timestamp), - prefix: i18n.translate('xpack.monitoring.clusterAlerts.clusterNeedsTSLEnabledDescription', { - defaultMessage: - 'Configuring TLS will be required to apply a Gold or Platinum license when security is enabled.', - }), - message: i18n.translate('xpack.monitoring.clusterAlerts.seeDocumentationDescription', { - defaultMessage: 'See documentation for details.', - }), - }; - } - - return null; -} - -/** - * Append the static alert(s) for this {@code cluster}, limiting the response to {@code size} {@code alerts}. - * - * @param {Object} cluster The cluster object containing the cluster's license. - * @param {Array} alerts The existing cluster alerts. - * @param {Number} size The maximum size. - * @return {Array} The alerts array (modified or not). - */ -export function appendStaticAlerts(cluster, alerts, size) { - const staticAlert = staticAlertForCluster(cluster); - - if (staticAlert) { - // we can put it over any resolved alert, or anything with a lower severity (which is currently none) - // the alerts array is pre-sorted from highest severity to lowest; unresolved alerts are at the bottom - const alertIndex = alerts.findIndex( - (alert) => alert.resolved_timestamp || alert.metadata.severity < staticAlert.metadata.severity - ); - - if (alertIndex !== -1) { - // we can put it in the place of this alert - alerts.splice(alertIndex, 0, staticAlert); - } else { - alerts.push(staticAlert); - } - - // chop off the last item if necessary (when size is < alerts.length) - return alerts.slice(0, size); - } - - return alerts; -} - -/** - * Create a filter that should be used when no time range is supplied and thus only un-resolved cluster alerts should - * be returned. - * - * @return {Object} Query to restrict to un-resolved cluster alerts. - */ -export function createFilterForUnresolvedAlerts() { - return { - bool: { - must_not: { - exists: { - field: 'resolved_timestamp', - }, - }, - }, - }; -} - -/** - * Create a filter that should be used when {@code options} has start or end times. - * - * This enables us to search for cluster alerts that have been resolved within the given time frame, while also - * grabbing any un-resolved cluster alerts. - * - * @param {Object} options The options for the cluster search. - * @return {Object} Query to restrict to un-resolved cluster alerts or cluster alerts resolved within the time range. - */ -export function createFilterForTime(options) { - const timeFilter = {}; - - if (options.start) { - timeFilter.gte = moment.utc(options.start).valueOf(); - } - - if (options.end) { - timeFilter.lte = moment.utc(options.end).valueOf(); - } - - return { - bool: { - should: [ - { - range: { - resolved_timestamp: { - format: 'epoch_millis', - ...timeFilter, - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'resolved_timestamp', - }, - }, - }, - }, - ], - }, - }; -} - -/** - * @param {Object} req Request object from the API route - * @param {String} cluster The cluster being checked - */ -export async function alertsClusterSearch(req, alertsIndex, cluster, checkLicense, options = {}) { - const verification = await verifyMonitoringLicense(req.server); - - if (!verification.enabled) { - return Promise.resolve({ message: verification.message }); - } - - const license = get(cluster, 'license', {}); - const prodLicenseInfo = checkLicense(license.type, license.status === 'active', 'production'); - - if (prodLicenseInfo.clusterAlerts.enabled) { - const config = req.server.config(); - const size = options.size || config.get('monitoring.ui.max_bucket_size'); - - const params = { - index: alertsIndex, - ignoreUnavailable: true, - filterPath: 'hits.hits._source', - body: { - size, - query: { - bool: { - must: [ - { - // This will cause anything un-resolved to be sorted above anything that is resolved - // From there, those items are sorted by their severity, then by their timestamp (age) - function_score: { - boost_mode: 'max', - functions: [ - { - filter: { - bool: { - must_not: [ - { - exists: { - field: 'resolved_timestamp', - }, - }, - ], - }, - }, - weight: 2, - }, - ], - }, - }, - ], - filter: [ - { - term: { 'metadata.cluster_uuid': cluster.cluster_uuid }, - }, - ], - }, - }, - sort: [ - '_score', - { 'metadata.severity': { order: 'desc' } }, - { timestamp: { order: 'asc' } }, - ], - }, - }; - - if (options.start || options.end) { - params.body.query.bool.filter.push(createFilterForTime(options)); - } else { - params.body.query.bool.filter.push(createFilterForUnresolvedAlerts()); - } - - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - return callWithRequest(req, 'search', params).then((result) => { - const hits = get(result, 'hits.hits', []); - const alerts = hits.map((alert) => alert._source); - - return appendStaticAlerts(cluster, alerts, size); - }); - } - - return Promise.resolve({ message: prodLicenseInfo.message }); -} diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.test.js b/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.test.js deleted file mode 100644 index 8b655e23cb430..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_cluster_search.test.js +++ /dev/null @@ -1,194 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { createStubs } from './__fixtures__/create_stubs'; -import { alertsClusterSearch } from './alerts_cluster_search'; - -const mockAlerts = [ - { - metadata: { - severity: 1, - }, - }, - { - metadata: { - severity: -1, - }, - }, - { - metadata: { - severity: 2000, - }, - resolved_timestamp: 'now', - }, -]; - -const mockQueryResult = { - hits: { - hits: [ - { - _source: mockAlerts[0], - }, - { - _source: mockAlerts[1], - }, - { - _source: mockAlerts[2], - }, - ], - }, -}; - -// TODO: tests were not running and are not up to date. -describe.skip('Alerts Cluster Search', () => { - describe('License checks pass', () => { - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ clusterAlerts: { enabled: true } }), - }); - const checkLicense = () => ({ clusterAlerts: { enabled: true } }); - - it('max hit count option', () => { - const { mockReq, callWithRequestStub } = createStubs(mockQueryResult, featureStub); - return alertsClusterSearch( - mockReq, - '.monitoring-alerts', - { cluster_uuid: 'cluster-1234' }, - checkLicense - ).then((alerts) => { - expect(alerts).to.eql(mockAlerts); - expect(callWithRequestStub.getCall(0).args[2].body.size).to.be.undefined; - }); - }); - - it('set hit count option', () => { - const { mockReq, callWithRequestStub } = createStubs(mockQueryResult, featureStub); - return alertsClusterSearch( - mockReq, - '.monitoring-alerts', - { cluster_uuid: 'cluster-1234' }, - checkLicense, - { size: 3 } - ).then((alerts) => { - expect(alerts).to.eql(mockAlerts); - expect(callWithRequestStub.getCall(0).args[2].body.size).to.be(3); - }); - }); - - it('should report static info-level alert in the right location', () => { - const { mockReq, callWithRequestStub } = createStubs(mockQueryResult, featureStub); - const cluster = { - cluster_uuid: 'cluster-1234', - timestamp: 'fake-timestamp', - version: '6.1.0-throwmeaway2', - license: { - cluster_needs_tls: true, - issue_date: 'fake-issue_date', - }, - }; - return alertsClusterSearch(mockReq, '.monitoring-alerts', cluster, checkLicense, { - size: 3, - }).then((alerts) => { - expect(alerts).to.have.length(3); - expect(alerts[0]).to.eql(mockAlerts[0]); - expect(alerts[1]).to.eql({ - metadata: { - severity: 0, - - cluster_uuid: cluster.cluster_uuid, - link: 'https://www.elastic.co/guide/en/x-pack/6.1/ssl-tls.html', - }, - update_timestamp: cluster.timestamp, - timestamp: cluster.license.issue_date, - prefix: - 'Configuring TLS will be required to apply a Gold or Platinum license when security is enabled.', - message: 'See documentation for details.', - }); - expect(alerts[2]).to.eql(mockAlerts[1]); - expect(callWithRequestStub.getCall(0).args[2].body.size).to.be(3); - }); - }); - - it('should report static info-level alert at the end if necessary', () => { - const { mockReq, callWithRequestStub } = createStubs({ hits: { hits: [] } }, featureStub); - const cluster = { - cluster_uuid: 'cluster-1234', - timestamp: 'fake-timestamp', - version: '6.1.0-throwmeaway2', - license: { - cluster_needs_tls: true, - issue_date: 'fake-issue_date', - }, - }; - return alertsClusterSearch(mockReq, '.monitoring-alerts', cluster, checkLicense, { - size: 3, - }).then((alerts) => { - expect(alerts).to.have.length(1); - expect(alerts[0]).to.eql({ - metadata: { - severity: 0, - cluster_uuid: cluster.cluster_uuid, - link: 'https://www.elastic.co/guide/en/x-pack/6.1/ssl-tls.html', - }, - update_timestamp: cluster.timestamp, - timestamp: cluster.license.issue_date, - prefix: - 'Configuring TLS will be required to apply a Gold or Platinum license when security is enabled.', - message: 'See documentation for details.', - }); - expect(callWithRequestStub.getCall(0).args[2].body.size).to.be(3); - }); - }); - }); - - describe('License checks fail', () => { - it('monitoring cluster license checks fail', () => { - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ - message: 'monitoring cluster license check fail', - clusterAlerts: { enabled: false }, - }), - }); - const checkLicense = sinon.stub(); - const { mockReq, callWithRequestStub } = createStubs({}, featureStub); - return alertsClusterSearch( - mockReq, - '.monitoring-alerts', - { cluster_uuid: 'cluster-1234' }, - checkLicense - ).then((alerts) => { - const result = { message: 'monitoring cluster license check fail' }; - expect(alerts).to.eql(result); - expect(checkLicense.called).to.be(false); - expect(callWithRequestStub.called).to.be(false); - }); - }); - - it('production cluster license checks fail', () => { - // monitoring cluster passes - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ clusterAlerts: { enabled: true } }), - }); - const checkLicense = sinon - .stub() - .returns({ clusterAlerts: { enabled: false }, message: 'prod goes boom' }); - const { mockReq, callWithRequestStub } = createStubs({}, featureStub); - return alertsClusterSearch( - mockReq, - '.monitoring-alerts', - { cluster_uuid: 'cluster-1234' }, - checkLicense - ).then((alerts) => { - const result = { message: 'prod goes boom' }; - expect(alerts).to.eql(result); - expect(checkLicense.calledOnce).to.be(true); - expect(callWithRequestStub.called).to.be(false); - }); - }); - }); -}); diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.js b/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.js deleted file mode 100644 index 5c4194d063612..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.js +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get, find } from 'lodash'; -import { verifyMonitoringLicense } from './verify_monitoring_license'; -import { i18n } from '@kbn/i18n'; - -export async function alertsClustersAggregation(req, alertsIndex, clusters, checkLicense) { - const verification = await verifyMonitoringLicense(req.server); - - if (!verification.enabled) { - // return metadata detailing that alerts is disabled because of the monitoring cluster license - return Promise.resolve({ alertsMeta: verification }); - } - - const params = { - index: alertsIndex, - ignoreUnavailable: true, - filterPath: 'aggregations', - body: { - size: 0, - query: { - bool: { - must_not: [ - { - exists: { field: 'resolved_timestamp' }, - }, - ], - }, - }, - aggs: { - group_by_cluster: { - terms: { - field: 'metadata.cluster_uuid', - size: 10, - }, - aggs: { - group_by_severity: { - range: { - field: 'metadata.severity', - ranges: [ - { - key: 'low', - to: 1000, - }, - { - key: 'medium', - from: 1000, - to: 2000, - }, - { - key: 'high', - from: 2000, - }, - ], - }, - }, - }, - }, - }, - }, - }; - - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - return callWithRequest(req, 'search', params).then((result) => { - const buckets = get(result.aggregations, 'group_by_cluster.buckets'); - const meta = { alertsMeta: { enabled: true } }; - - return clusters.reduce((reClusters, cluster) => { - let alerts; - - const license = cluster.license || {}; - // check the license type of the production cluster for alerts feature support - const prodLicenseInfo = checkLicense(license.type, license.status === 'active', 'production'); - if (prodLicenseInfo.clusterAlerts.enabled) { - const clusterNeedsTLS = get(license, 'cluster_needs_tls', false); - const staticAlertCount = clusterNeedsTLS ? 1 : 0; - const bucket = find(buckets, { key: cluster.cluster_uuid }); - const bucketDocCount = get(bucket, 'doc_count', 0); - let severities = {}; - - if (bucket || staticAlertCount > 0) { - if (bucketDocCount > 0 || staticAlertCount > 0) { - const groupBySeverityBuckets = get(bucket, 'group_by_severity.buckets', []); - const lowGroup = find(groupBySeverityBuckets, { key: 'low' }) || {}; - const mediumGroup = find(groupBySeverityBuckets, { key: 'medium' }) || {}; - const highGroup = find(groupBySeverityBuckets, { key: 'high' }) || {}; - severities = { - low: (lowGroup.doc_count || 0) + staticAlertCount, - medium: mediumGroup.doc_count || 0, - high: highGroup.doc_count || 0, - }; - } - - alerts = { - count: bucketDocCount + staticAlertCount, - ...severities, - }; - } - } else { - // add metadata to the cluster's alerts object detailing that alerts are disabled because of the prod cluster license - alerts = { - clusterMeta: { - enabled: false, - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.unsupportedClusterAlertsDescription', - { - defaultMessage: - 'Cluster [{clusterName}] license type [{licenseType}] does not support Cluster Alerts', - values: { - clusterName: cluster.cluster_name, - licenseType: `${license.type}`, - }, - } - ), - }, - }; - } - - return Object.assign(reClusters, { [cluster.cluster_uuid]: alerts }); - }, meta); - }); -} diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.test.js b/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.test.js deleted file mode 100644 index fcf840ebf6636..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/alerts_clusters_aggregation.test.js +++ /dev/null @@ -1,235 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { merge } from 'lodash'; -import { createStubs } from './__fixtures__/create_stubs'; -import { alertsClustersAggregation } from './alerts_clusters_aggregation'; - -const clusters = [ - { - cluster_uuid: 'cluster-abc0', - cluster_name: 'cluster-abc0-name', - license: { type: 'test_license' }, - }, - { - cluster_uuid: 'cluster-abc1', - cluster_name: 'cluster-abc1-name', - license: { type: 'test_license' }, - }, - { - cluster_uuid: 'cluster-abc2', - cluster_name: 'cluster-abc2-name', - license: { type: 'test_license' }, - }, - { - cluster_uuid: 'cluster-abc3', - cluster_name: 'cluster-abc3-name', - license: { type: 'test_license' }, - }, - { cluster_uuid: 'cluster-no-license', cluster_name: 'cluster-no-license-name' }, - { cluster_uuid: 'cluster-invalid', cluster_name: 'cluster-invalid-name', license: {} }, -]; -const mockQueryResult = { - aggregations: { - group_by_cluster: { - buckets: [ - { - key: 'cluster-abc1', - doc_count: 1, - group_by_severity: { - buckets: [{ key: 'low', doc_count: 1 }], - }, - }, - { - key: 'cluster-abc2', - doc_count: 2, - group_by_severity: { - buckets: [{ key: 'medium', doc_count: 2 }], - }, - }, - { - key: 'cluster-abc3', - doc_count: 3, - group_by_severity: { - buckets: [{ key: 'high', doc_count: 3 }], - }, - }, - ], - }, - }, -}; - -// TODO: tests were not running and are not up to date. -describe.skip('Alerts Clusters Aggregation', () => { - describe('with alerts enabled', () => { - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ clusterAlerts: { enabled: true } }), - }); - const checkLicense = () => ({ clusterAlerts: { enabled: true } }); - - it('aggregates alert count summary by cluster', () => { - const { mockReq } = createStubs(mockQueryResult, featureStub); - return alertsClustersAggregation(mockReq, '.monitoring-alerts', clusters, checkLicense).then( - (result) => { - expect(result).to.eql({ - alertsMeta: { enabled: true }, - 'cluster-abc0': undefined, - 'cluster-abc1': { - count: 1, - high: 0, - low: 1, - medium: 0, - }, - 'cluster-abc2': { - count: 2, - high: 0, - low: 0, - medium: 2, - }, - 'cluster-abc3': { - count: 3, - high: 3, - low: 0, - medium: 0, - }, - 'cluster-no-license': undefined, - 'cluster-invalid': undefined, - }); - } - ); - }); - - it('aggregates alert count summary by cluster include static alert', () => { - const { mockReq } = createStubs(mockQueryResult, featureStub); - const clusterLicenseNeedsTLS = { license: { cluster_needs_tls: true } }; - const newClusters = Array.from(clusters); - - newClusters[0] = merge({}, clusters[0], clusterLicenseNeedsTLS); - newClusters[1] = merge({}, clusters[1], clusterLicenseNeedsTLS); - - return alertsClustersAggregation( - mockReq, - '.monitoring-alerts', - newClusters, - checkLicense - ).then((result) => { - expect(result).to.eql({ - alertsMeta: { enabled: true }, - 'cluster-abc0': { - count: 1, - high: 0, - medium: 0, - low: 1, - }, - 'cluster-abc1': { - count: 2, - high: 0, - low: 2, - medium: 0, - }, - 'cluster-abc2': { - count: 2, - high: 0, - low: 0, - medium: 2, - }, - 'cluster-abc3': { - count: 3, - high: 3, - low: 0, - medium: 0, - }, - 'cluster-no-license': undefined, - 'cluster-invalid': undefined, - }); - }); - }); - }); - - describe('with alerts disabled due to license', () => { - it('returns the input set if disabled because monitoring cluster checks', () => { - // monitoring clusters' license check to fail - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ - clusterAlerts: { enabled: false }, - message: 'monitoring cluster license is fail', - }), - }); - // prod clusters' license check to pass - const checkLicense = () => ({ clusterAlerts: { enabled: true } }); - const { mockReq } = createStubs(mockQueryResult, featureStub); - - return alertsClustersAggregation(mockReq, '.monitoring-alerts', clusters, checkLicense).then( - (result) => { - expect(result).to.eql({ - alertsMeta: { enabled: false, message: 'monitoring cluster license is fail' }, - }); - } - ); - }); - - it('returns the input set if disabled because production cluster checks', () => { - // monitoring clusters' license check to pass - const featureStub = sinon.stub().returns({ - getLicenseCheckResults: () => ({ clusterAlerts: { enabled: true } }), - }); - // prod clusters license check to fail - const checkLicense = () => ({ clusterAlerts: { enabled: false } }); - const { mockReq } = createStubs(mockQueryResult, featureStub); - - return alertsClustersAggregation(mockReq, '.monitoring-alerts', clusters, checkLicense).then( - (result) => { - expect(result).to.eql({ - alertsMeta: { enabled: true }, - 'cluster-abc0': { - clusterMeta: { - enabled: false, - message: - 'Cluster [cluster-abc0-name] license type [test_license] does not support Cluster Alerts', - }, - }, - 'cluster-abc1': { - clusterMeta: { - enabled: false, - message: - 'Cluster [cluster-abc1-name] license type [test_license] does not support Cluster Alerts', - }, - }, - 'cluster-abc2': { - clusterMeta: { - enabled: false, - message: - 'Cluster [cluster-abc2-name] license type [test_license] does not support Cluster Alerts', - }, - }, - 'cluster-abc3': { - clusterMeta: { - enabled: false, - message: - 'Cluster [cluster-abc3-name] license type [test_license] does not support Cluster Alerts', - }, - }, - 'cluster-no-license': { - clusterMeta: { - enabled: false, - message: `Cluster [cluster-no-license-name] license type [undefined] does not support Cluster Alerts`, - }, - }, - 'cluster-invalid': { - clusterMeta: { - enabled: false, - message: `Cluster [cluster-invalid-name] license type [undefined] does not support Cluster Alerts`, - }, - }, - }); - } - ); - }); - }); -}); diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/check_license.js b/x-pack/plugins/monitoring/server/cluster_alerts/check_license.js deleted file mode 100644 index 1010c7c8d5036..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/check_license.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { includes } from 'lodash'; -import { i18n } from '@kbn/i18n'; - -/** - * Function to do the work of checking license for cluster alerts feature support - * Can be used to power XpackInfo license check results as well as checking license of monitored clusters - * - * @param {String} type License type if a valid license. {@code null} if license was deleted. - * @param {Boolean} active Indicating that the overall license is active - * @param {String} clusterSource 'monitoring' or 'production' - * @param {Boolean} watcher {@code true} if Watcher is provided (or if its availability should not be checked) - */ -export function checkLicense(type, active, clusterSource, watcher = true) { - // return object, set up with safe defaults - const licenseInfo = { - clusterAlerts: { enabled: false }, - }; - - // Disabled because there is no license - if (!type) { - return Object.assign(licenseInfo, { - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.checkLicense.licenseNotDeterminedDescription', - { - defaultMessage: `Cluster Alerts are not displayed because the [{clusterSource}] cluster's license could not be determined.`, - values: { - clusterSource, - }, - } - ), - }); - } - - // Disabled because the license type is not valid (basic) - if (!includes(['trial', 'standard', 'gold', 'platinum', 'enterprise'], type)) { - return Object.assign(licenseInfo, { - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.checkLicense.licenseIsBasicDescription', - { - defaultMessage: `Cluster Alerts are not displayed if Watcher is disabled or the [{clusterSource}] cluster's current license is Basic.`, - values: { - clusterSource, - }, - } - ), - }); - } - - // Disabled because the license is inactive - if (!active) { - return Object.assign(licenseInfo, { - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.checkLicense.licenseNotActiveDescription', - { - defaultMessage: `Cluster Alerts are not displayed because the [{clusterSource}] cluster's current license [{type}] is not active.`, - values: { - clusterSource, - type, - }, - } - ), - }); - } - - // Disabled because Watcher is not enabled (it may or may not be available) - if (!watcher) { - return Object.assign(licenseInfo, { - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.checkLicense.watcherIsDisabledDescription', - { - defaultMessage: 'Cluster Alerts are not enabled because Watcher is disabled.', - } - ), - }); - } - - return Object.assign(licenseInfo, { clusterAlerts: { enabled: true } }); -} - -/** - * Function to "generate" license check results for {@code xpackInfo}. - * - * @param {Object} xpackInfo license information for the _Monitoring_ cluster - * @param {Function} _checkLicense Method exposed for easier unit testing - * @returns {Object} Response from {@code checker} - */ -export function checkLicenseGenerator(xpackInfo, _checkLicense = checkLicense) { - let type; - let active = false; - let watcher = false; - - if (xpackInfo && xpackInfo.license) { - const watcherFeature = xpackInfo.feature('watcher'); - - if (watcherFeature) { - watcher = watcherFeature.isEnabled(); - } - - type = xpackInfo.license.getType(); - active = xpackInfo.license.isActive(); - } - - return _checkLicense(type, active, 'monitoring', watcher); -} diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/check_license.test.js b/x-pack/plugins/monitoring/server/cluster_alerts/check_license.test.js deleted file mode 100644 index 2217d27dd0c00..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/check_license.test.js +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { checkLicense, checkLicenseGenerator } from './check_license'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -describe('Monitoring Check License', () => { - describe('License undeterminable', () => { - it('null active license - results false with a message', () => { - const result = checkLicense(null, true, 'test-cluster-abc'); - expect(result).to.eql({ - clusterAlerts: { enabled: false }, - message: `Cluster Alerts are not displayed because the [test-cluster-abc] cluster's license could not be determined.`, - }); - }); - }); - - describe('Inactive license', () => { - it('platinum inactive license - results false with a message', () => { - const result = checkLicense('platinum', false, 'test-cluster-def'); - expect(result).to.eql({ - clusterAlerts: { enabled: false }, - message: `Cluster Alerts are not displayed because the [test-cluster-def] cluster's current license [platinum] is not active.`, - }); - }); - }); - - describe('Active license', () => { - describe('Unsupported license types', () => { - it('basic active license - results false with a message', () => { - const result = checkLicense('basic', true, 'test-cluster-ghi'); - expect(result).to.eql({ - clusterAlerts: { enabled: false }, - message: `Cluster Alerts are not displayed if Watcher is disabled or the [test-cluster-ghi] cluster's current license is Basic.`, - }); - }); - }); - - describe('Supported license types', () => { - it('standard active license - results true with no message', () => { - const result = checkLicense('standard', true, 'test-cluster-jkl'); - expect(result).to.eql({ - clusterAlerts: { enabled: true }, - }); - }); - - it('gold active license - results true with no message', () => { - const result = checkLicense('gold', true, 'test-cluster-mno'); - expect(result).to.eql({ - clusterAlerts: { enabled: true }, - }); - }); - - it('platinum active license - results true with no message', () => { - const result = checkLicense('platinum', true, 'test-cluster-pqr'); - expect(result).to.eql({ - clusterAlerts: { enabled: true }, - }); - }); - - it('enterprise active license - results true with no message', () => { - const result = checkLicense('enterprise', true, 'test-cluster-pqr'); - expect(result).to.eql({ - clusterAlerts: { enabled: true }, - }); - }); - - describe('Watcher is not enabled', () => { - it('platinum active license - watcher disabled - results false with message', () => { - const result = checkLicense('platinum', true, 'test-cluster-pqr', false); - expect(result).to.eql({ - clusterAlerts: { enabled: false }, - message: 'Cluster Alerts are not enabled because Watcher is disabled.', - }); - }); - }); - }); - }); - - describe('XPackInfo checkLicenseGenerator', () => { - it('with deleted license', () => { - const expected = 123; - const checker = sinon.stub().returns(expected); - const result = checkLicenseGenerator(null, checker); - - expect(result).to.be(expected); - expect(checker.withArgs(undefined, false, 'monitoring', false).called).to.be(true); - }); - - it('license without watcher', () => { - const expected = 123; - const xpackInfo = { - license: { - getType: () => 'fake-type', - isActive: () => true, - }, - feature: () => null, - }; - const checker = sinon.stub().returns(expected); - const result = checkLicenseGenerator(xpackInfo, checker); - - expect(result).to.be(expected); - expect(checker.withArgs('fake-type', true, 'monitoring', false).called).to.be(true); - }); - - it('mock license with watcher', () => { - const expected = 123; - const feature = sinon - .stub() - .withArgs('watcher') - .returns({ isEnabled: () => true }); - const xpackInfo = { - license: { - getType: () => 'another-type', - isActive: () => true, - }, - feature, - }; - const checker = sinon.stub().returns(expected); - const result = checkLicenseGenerator(xpackInfo, checker); - - expect(result).to.be(expected); - expect(feature.withArgs('watcher').calledOnce).to.be(true); - expect(checker.withArgs('another-type', true, 'monitoring', true).called).to.be(true); - }); - - it('platinum license with watcher', () => { - const xpackInfo = { - license: { - getType: () => 'platinum', - isActive: () => true, - }, - feature: () => { - return { - isEnabled: () => true, - }; - }, - }; - const result = checkLicenseGenerator(xpackInfo); - - expect(result).to.eql({ clusterAlerts: { enabled: true } }); - }); - }); -}); diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.js b/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.js deleted file mode 100644 index e93db4ea96095..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; - -/** - * Determine if an API for Cluster Alerts should respond based on the license and configuration of the monitoring cluster. - * - * Note: This does not guarantee that any production cluster has a valid license; only that Cluster Alerts in general can be used! - * - * @param {Object} server Server object containing config and plugins - * @return {Boolean} {@code true} to indicate that cluster alerts can be used. - */ -export async function verifyMonitoringLicense(server) { - const config = server.config(); - - // if cluster alerts are enabled, then ensure that we can use it according to the license - if (config.get('monitoring.cluster_alerts.enabled')) { - const xpackInfo = get(server.plugins.monitoring, 'info'); - if (xpackInfo) { - const licenseService = await xpackInfo.getLicenseService(); - const watcherFeature = licenseService.getWatcherFeature(); - return { - enabled: watcherFeature.isEnabled, - message: licenseService.getMessage(), - }; - } - - return { - enabled: false, - message: i18n.translate('xpack.monitoring.clusterAlerts.notDeterminedLicenseDescription', { - defaultMessage: 'Status of Cluster Alerts feature could not be determined.', - }), - }; - } - - return { - enabled: false, - message: i18n.translate('xpack.monitoring.clusterAlerts.disabledLicenseDescription', { - defaultMessage: 'Cluster Alerts feature is disabled.', - }), - }; -} diff --git a/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.test.js b/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.test.js deleted file mode 100644 index 6add3131bed96..0000000000000 --- a/x-pack/plugins/monitoring/server/cluster_alerts/verify_monitoring_license.test.js +++ /dev/null @@ -1,88 +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 { verifyMonitoringLicense } from './verify_monitoring_license'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -// TODO: tests were not running and are not up to date. -describe.skip('Monitoring Verify License', () => { - describe('Disabled by Configuration', () => { - const get = sinon.stub().withArgs('xpack.monitoring.cluster_alerts.enabled').returns(false); - const server = { config: sinon.stub().returns({ get }) }; - - it('verifyMonitoringLicense returns false without checking the license', () => { - const verification = verifyMonitoringLicense(server); - - expect(verification.enabled).to.be(false); - expect(verification.message).to.be('Cluster Alerts feature is disabled.'); - - expect(get.withArgs('xpack.monitoring.cluster_alerts.enabled').calledOnce).to.be(true); - }); - }); - - describe('Enabled by Configuration', () => { - it('verifyMonitoringLicense returns false if enabled by configuration, but not by license', () => { - const get = sinon.stub().withArgs('xpack.monitoring.cluster_alerts.enabled').returns(true); - const server = { - config: sinon.stub().returns({ get }), - plugins: { monitoring: { info: {} } }, - }; - const getLicenseCheckResults = sinon - .stub() - .returns({ clusterAlerts: { enabled: false }, message: 'failed!!' }); - const feature = sinon.stub().withArgs('monitoring').returns({ getLicenseCheckResults }); - - server.plugins.monitoring.info = { feature }; - - const verification = verifyMonitoringLicense(server); - - expect(verification.enabled).to.be(false); - expect(verification.message).to.be('failed!!'); - - expect(get.withArgs('xpack.monitoring.cluster_alerts.enabled').calledOnce).to.be(true); - expect(feature.withArgs('monitoring').calledOnce).to.be(true); - expect(getLicenseCheckResults.calledOnce).to.be(true); - }); - - it('verifyMonitoringLicense returns true if enabled by configuration and by license', () => { - const get = sinon.stub().withArgs('xpack.monitoring.cluster_alerts.enabled').returns(true); - const server = { - config: sinon.stub().returns({ get }), - plugins: { monitoring: { info: {} } }, - }; - const getLicenseCheckResults = sinon.stub().returns({ clusterAlerts: { enabled: true } }); - const feature = sinon.stub().withArgs('monitoring').returns({ getLicenseCheckResults }); - - server.plugins.monitoring.info = { feature }; - - const verification = verifyMonitoringLicense(server); - - expect(verification.enabled).to.be(true); - expect(verification.message).to.be.undefined; - - expect(get.withArgs('xpack.monitoring.cluster_alerts.enabled').calledOnce).to.be(true); - expect(feature.withArgs('monitoring').calledOnce).to.be(true); - expect(getLicenseCheckResults.calledOnce).to.be(true); - }); - }); - - it('Monitoring feature info cannot be determined', () => { - const get = sinon.stub().withArgs('xpack.monitoring.cluster_alerts.enabled').returns(true); - const server = { - config: sinon.stub().returns({ get }), - plugins: { monitoring: undefined }, // simulate race condition - }; - - const verification = verifyMonitoringLicense(server); - - expect(verification.enabled).to.be(false); - expect(verification.message).to.be('Status of Cluster Alerts feature could not be determined.'); - - expect(get.withArgs('xpack.monitoring.cluster_alerts.enabled').calledOnce).to.be(true); - }); -}); diff --git a/x-pack/plugins/monitoring/server/deprecations.test.js b/x-pack/plugins/monitoring/server/deprecations.test.js index 156fc76b6e076..d7e1a2340d295 100644 --- a/x-pack/plugins/monitoring/server/deprecations.test.js +++ b/x-pack/plugins/monitoring/server/deprecations.test.js @@ -36,25 +36,9 @@ describe('monitoring plugin deprecations', function () { expect(log).not.toHaveBeenCalled(); }); - it(`shouldn't log when cluster alerts are disabled`, function () { - const settings = { - cluster_alerts: { - enabled: false, - email_notifications: { - enabled: true, - }, - }, - }; - - const log = jest.fn(); - transformDeprecations(settings, fromPath, log); - expect(log).not.toHaveBeenCalled(); - }); - it(`shouldn't log when email_address is specified`, function () { const settings = { cluster_alerts: { - enabled: true, email_notifications: { enabled: true, email_address: 'foo@bar.com', @@ -70,7 +54,6 @@ describe('monitoring plugin deprecations', function () { it(`should log when email_address is missing, but alerts/notifications are both enabled`, function () { const settings = { cluster_alerts: { - enabled: true, email_notifications: { enabled: true, }, diff --git a/x-pack/plugins/monitoring/server/deprecations.ts b/x-pack/plugins/monitoring/server/deprecations.ts index 47a01385c6308..a276cfcee0d35 100644 --- a/x-pack/plugins/monitoring/server/deprecations.ts +++ b/x-pack/plugins/monitoring/server/deprecations.ts @@ -45,9 +45,7 @@ export const deprecations = ({ ), renameFromRoot('xpack.monitoring', 'monitoring'), (config, fromPath, logger) => { - const clusterAlertsEnabled = get(config, 'cluster_alerts.enabled'); - const emailNotificationsEnabled = - clusterAlertsEnabled && get(config, 'cluster_alerts.email_notifications.enabled'); + const emailNotificationsEnabled = get(config, 'cluster_alerts.email_notifications.enabled'); if (emailNotificationsEnabled && !get(config, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) { logger( `Config key [${fromPath}.${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}] will be required for email notifications to work in 7.0."` diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts index ecfb5fc50a16d..603c66d2d05f2 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; @@ -25,7 +26,7 @@ describe('fetchAvailableCcs', () => { elasticsearchClientMock.createSuccessTransportRequestPromise({ [connectedRemote]: { connected: true, - }, + } as estypes.RemoteInfo, }) ); @@ -40,7 +41,7 @@ describe('fetchAvailableCcs', () => { elasticsearchClientMock.createSuccessTransportRequestPromise({ [disconnectedRemote]: { connected: false, - }, + } as estypes.RemoteInfo, }) ); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts index 330be4e90ed56..fb67cd1805950 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts @@ -69,8 +69,8 @@ export async function fetchCCRReadExceptions( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -95,6 +95,7 @@ export async function fetchCCRReadExceptions( const { body: response } = await esClient.search(params); const stats: CCRReadExceptionsStats[] = []; + // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets const { buckets: remoteClusterBuckets = [] } = response.aggregations.remote_clusters; if (!remoteClusterBuckets.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.test.ts index d326c7f4bedda..08ecaef33085b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { fetchClusterHealth } from './fetch_cluster_health'; @@ -29,7 +30,7 @@ describe('fetchClusterHealth', () => { }, ], }, - }) + } as estypes.SearchResponse) ); const clusters = [{ clusterUuid, clusterName: 'foo' }]; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts index be91aaa6ec983..ddbf4e3d4b3c1 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cluster_health.ts @@ -25,8 +25,8 @@ export async function fetchClusterHealth( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -59,11 +59,11 @@ export async function fetchClusterHealth( }, }; - const { body: response } = await esClient.search(params); - return response.hits.hits.map((hit: { _source: ElasticsearchSource; _index: string }) => { + const { body: response } = await esClient.search(params); + return response.hits.hits.map((hit) => { return { - health: hit._source.cluster_state?.status, - clusterUuid: hit._source.cluster_uuid, + health: hit._source!.cluster_state?.status, + clusterUuid: hit._source!.cluster_uuid, ccs: hit._index.includes(':') ? hit._index.split(':')[0] : undefined, } as AlertClusterHealth; }); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts index 54aa2e68d4ef2..75991e892d419 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; @@ -28,7 +29,7 @@ describe('fetchClusters', () => { }, ], }, - }) + } as estypes.SearchResponse) ); const index = '.monitoring-es-*'; const result = await fetchClusters(esClient, index); @@ -57,7 +58,7 @@ describe('fetchClusters', () => { }, ], }, - }) + } as estypes.SearchResponse) ); const index = '.monitoring-es-*'; const result = await fetchClusters(esClient, index); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts index 2ff9ae3854e4a..0fb9dd5298e9e 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { fetchCpuUsageNodeStats } from './fetch_cpu_usage_node_stats'; @@ -24,6 +25,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch normal stats', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -77,6 +79,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch container stats', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -143,6 +146,7 @@ describe('fetchCpuUsageNodeStats', () => { it('fetch properly return ccs', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -193,7 +197,9 @@ describe('fetchCpuUsageNodeStats', () => { let params = null; esClient.search.mockImplementation((...args) => { params = args[0]; - return elasticsearchClientMock.createSuccessTransportRequestPromise({}); + return elasticsearchClientMock.createSuccessTransportRequestPromise( + {} as estypes.SearchResponse + ); }); await fetchCpuUsageNodeStats(esClient, clusters, index, startMs, endMs, size); expect(params).toStrictEqual({ diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.ts index 1dfbe381b9956..07ca3572ad6b3 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_cpu_usage_node_stats.ts @@ -120,14 +120,14 @@ export async function fetchCpuUsageNodeStats( usage_deriv: { derivative: { buckets_path: 'average_usage', - gap_policy: 'skip', + gap_policy: 'skip' as const, unit: NORMALIZED_DERIVATIVE_UNIT, }, }, periods_deriv: { derivative: { buckets_path: 'average_periods', - gap_policy: 'skip', + gap_policy: 'skip' as const, unit: NORMALIZED_DERIVATIVE_UNIT, }, }, diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts index 7664d73f6009b..8faf79fc4b59c 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.test.ts @@ -25,6 +25,7 @@ describe('fetchDiskUsageNodeStats', () => { it('fetch normal stats', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts index aea4ede825d67..30daee225fcb4 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts @@ -101,7 +101,8 @@ export async function fetchDiskUsageNodeStats( const { body: response } = await esClient.search(params); const stats: AlertDiskUsageNodeStats[] = []; - const { buckets: clusterBuckets = [] } = response.aggregations.clusters; + // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets + const { buckets: clusterBuckets = [] } = response.aggregations!.clusters; if (!clusterBuckets.length) { return stats; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.test.ts index be501ee3d5280..d105174853636 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.test.ts @@ -9,6 +9,7 @@ import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; import { fetchElasticsearchVersions } from './fetch_elasticsearch_versions'; +import { estypes } from '@elastic/elasticsearch'; describe('fetchElasticsearchVersions', () => { const esClient = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; @@ -41,7 +42,7 @@ describe('fetchElasticsearchVersions', () => { }, ], }, - }) + } as estypes.SearchResponse) ); const result = await fetchElasticsearchVersions(esClient, clusters, index, size); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts index b4b7739f6731b..111ef5b0c120d 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_elasticsearch_versions.ts @@ -26,8 +26,8 @@ export async function fetchElasticsearchVersions( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -60,13 +60,13 @@ export async function fetchElasticsearchVersions( }, }; - const { body: response } = await esClient.search(params); - return response.hits.hits.map((hit: { _source: ElasticsearchSource; _index: string }) => { - const versions = hit._source.cluster_stats?.nodes?.versions; + const { body: response } = await esClient.search(params); + return response.hits.hits.map((hit) => { + const versions = hit._source!.cluster_stats?.nodes?.versions ?? []; return { versions, - clusterUuid: hit._source.cluster_uuid, - ccs: hit._index.includes(':') ? hit._index.split(':')[0] : null, + clusterUuid: hit._source!.cluster_uuid, + ccs: hit._index.includes(':') ? hit._index.split(':')[0] : undefined, }; }); } 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 dfba0c42eef3d..f51e1cde47f8d 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 @@ -88,8 +88,8 @@ export async function fetchIndexShardSize( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -116,6 +116,7 @@ export async function fetchIndexShardSize( const { body: response } = await esClient.search(params); const stats: IndexShardSizeStats[] = []; + // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets const { buckets: clusterBuckets = [] } = response.aggregations.clusters; const validIndexPatterns = memoizedIndexPatterns(shardIndexPatterns); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts index 901851d766512..2b966b16f2f5c 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.test.ts @@ -23,6 +23,7 @@ describe('fetchKibanaVersions', () => { it('fetch as expected', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.ts index a4e1e606702ec..cb2f201e2586e 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_kibana_versions.ts @@ -70,7 +70,7 @@ export async function fetchKibanaVersions( field: 'kibana_stats.kibana.version', size: 1, order: { - latest_report: 'desc', + latest_report: 'desc' as const, }, }, aggs: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts index 69a42812bfe88..3c12c70bf1713 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts @@ -8,6 +8,7 @@ import { fetchLicenses } from './fetch_licenses'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { estypes } from '@elastic/elasticsearch'; describe('fetchLicenses', () => { const clusterName = 'MyCluster'; @@ -32,7 +33,7 @@ describe('fetchLicenses', () => { }, ], }, - }) + } as estypes.SearchResponse) ); const clusters = [{ clusterUuid, clusterName }]; const index = '.monitoring-es-*'; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts index 5cd4378f0a747..5178b6c4c53a7 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_licenses.ts @@ -6,7 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; import { AlertLicense, AlertCluster } from '../../../common/types/alerts'; -import { ElasticsearchResponse } from '../../../common/types/es'; +import { ElasticsearchSource } from '../../../common/types/es'; export async function fetchLicenses( esClient: ElasticsearchClient, @@ -25,8 +25,8 @@ export async function fetchLicenses( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -59,15 +59,15 @@ export async function fetchLicenses( }, }; - const { body: response } = await esClient.search(params); + const { body: response } = await esClient.search(params); return ( response?.hits?.hits.map((hit) => { - const rawLicense = hit._source.license ?? {}; + const rawLicense = hit._source!.license ?? {}; const license: AlertLicense = { status: rawLicense.status ?? '', type: rawLicense.type ?? '', expiryDateMS: rawLicense.expiry_date_in_millis ?? 0, - clusterUuid: hit._source.cluster_uuid, + clusterUuid: hit._source!.cluster_uuid, ccs: hit._index, }; return license; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts index e35de6e68866d..d7d4e6531f58e 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.test.ts @@ -23,6 +23,7 @@ describe('fetchLogstashVersions', () => { it('fetch as expected', async () => { esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { index: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.ts index 6090ba36d9749..6fb54857d40e4 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_logstash_versions.ts @@ -70,7 +70,7 @@ export async function fetchLogstashVersions( field: 'logstash_stats.logstash.version', size: 1, order: { - latest_report: 'desc', + latest_report: 'desc' as const, }, }, aggs: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts index 77c17a8ebf3ef..aad4638bf8359 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts @@ -94,6 +94,7 @@ export async function fetchMemoryUsageNodeStats( const { body: response } = await esClient.search(params); const stats: AlertMemoryUsageNodeStats[] = []; + // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets const { buckets: clusterBuckets = [] } = response.aggregations.clusters; if (!clusterBuckets.length) { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts index 2388abf024eb9..c8d15acf8ff73 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts @@ -56,6 +56,7 @@ describe('fetchMissingMonitoringData', () => { ]; esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { @@ -114,6 +115,7 @@ describe('fetchMissingMonitoringData', () => { }, ]; esClient.search.mockReturnValue( + // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values elasticsearchClientMock.createSuccessTransportRequestPromise({ aggregations: { clusters: { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts index cb274848e6c5a..a7b4a3a023207 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts @@ -99,8 +99,8 @@ export async function fetchMissingMonitoringData( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts index a97594c8ca995..ff3a8d4aa7ef8 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_nodes_from_cluster_stats.ts @@ -36,8 +36,8 @@ export async function fetchNodesFromClusterStats( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -71,8 +71,8 @@ export async function fetchNodesFromClusterStats( sort: [ { timestamp: { - order: 'desc', - unmapped_type: 'long', + order: 'desc' as const, + unmapped_type: 'long' as const, }, }, ], @@ -90,6 +90,7 @@ export async function fetchNodesFromClusterStats( const { body: response } = await esClient.search(params); const nodes = []; + // @ts-expect-error @elastic/elasticsearch Aggregate does not define buckets const clusterBuckets = response.aggregations.clusters.buckets; for (const clusterBucket of clusterBuckets) { const clusterUuid = clusterBucket.key; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts index 5770721195e14..b63244dab719d 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts @@ -13,13 +13,13 @@ const invalidNumberValue = (value: number) => { return isNaN(value) || value === undefined || value === null; }; -const getTopHits = (threadType: string, order: string) => ({ +const getTopHits = (threadType: string, order: 'asc' | 'desc') => ({ top_hits: { sort: [ { timestamp: { order, - unmapped_type: 'long', + unmapped_type: 'long' as const, }, }, ], @@ -81,10 +81,10 @@ export async function fetchThreadPoolRejectionStats( }, aggs: { most_recent: { - ...getTopHits(threadType, 'desc'), + ...getTopHits(threadType, 'desc' as const), }, least_recent: { - ...getTopHits(threadType, 'asc'), + ...getTopHits(threadType, 'asc' as const), }, }, }, @@ -96,6 +96,7 @@ export async function fetchThreadPoolRejectionStats( const { body: response } = await esClient.search(params); const stats: AlertThreadPoolRejectionsStats[] = []; + // @ts-expect-error @elastic/elasticsearch Aggregate does not specify buckets const { buckets: clusterBuckets = [] } = response.aggregations.clusters; if (!clusterBuckets.length) { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js index b282cf94ade28..5143613a25b9c 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js @@ -15,8 +15,6 @@ import { getKibanasForClusters } from '../kibana'; import { getLogstashForClusters } from '../logstash'; import { getLogstashPipelineIds } from '../logstash/get_pipeline_ids'; import { getBeatsForClusters } from '../beats'; -import { verifyMonitoringLicense } from '../../cluster_alerts/verify_monitoring_license'; -import { checkLicense as checkLicenseForAlerts } from '../../cluster_alerts/check_license'; import { getClustersSummary } from './get_clusters_summary'; import { STANDALONE_CLUSTER_CLUSTER_UUID, @@ -127,20 +125,7 @@ export async function getClustersFromRequest( clusters.map((cluster) => cluster.cluster_uuid) ); - const verification = await verifyMonitoringLicense(req.server); for (const cluster of clusters) { - if (!verification.enabled) { - // return metadata detailing that alerts is disabled because of the monitoring cluster license - cluster.alerts = { - alertsMeta: { - enabled: verification.enabled, - message: verification.message, // NOTE: this is only defined when the alert feature is disabled - }, - list: {}, - }; - continue; - } - if (!alertsClient) { cluster.alerts = { list: {}, @@ -148,17 +133,7 @@ export async function getClustersFromRequest( enabled: false, }, }; - continue; - } - - // check the license type of the production cluster for alerts feature support - const license = cluster.license || {}; - const prodLicenseInfo = checkLicenseForAlerts( - license.type, - license.status === 'active', - 'production' - ); - if (prodLicenseInfo.clusterAlerts.enabled) { + } else { try { cluster.alerts = { list: Object.keys(alertStatus).reduce((accum, alertName) => { @@ -190,29 +165,7 @@ export async function getClustersFromRequest( }, }; } - continue; } - - cluster.alerts = { - list: {}, - alertsMeta: { - enabled: false, - }, - clusterMeta: { - enabled: false, - message: i18n.translate( - 'xpack.monitoring.clusterAlerts.unsupportedClusterAlertsDescription', - { - defaultMessage: - 'Cluster [{clusterName}] license type [{licenseType}] does not support Cluster Alerts', - values: { - clusterName: cluster.cluster_name, - licenseType: `${license.type}`, - }, - } - ), - }, - }; } } } diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 05abac80b67ce..cb6ea799078a2 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -6,3 +6,4 @@ */ export const enableAlertingExperience = 'observability:enableAlertingExperience'; +export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index 33cc6d24397d6..d06b3822c2571 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -9,7 +9,7 @@ import { createMemoryHistory } from 'history'; import React from 'react'; import { Observable } from 'rxjs'; import { AppMountParameters, CoreStart } from 'src/core/public'; -import { ObservabilityPluginSetupDeps } from '../plugin'; +import { ObservabilityPublicPluginsStart } from '../plugin'; import { renderApp } from './'; describe('renderApp', () => { @@ -32,7 +32,7 @@ describe('renderApp', () => { }, }, }, - } as unknown) as ObservabilityPluginSetupDeps; + } as unknown) as ObservabilityPublicPluginsStart; const core = ({ application: { currentAppId$: new Observable(), navigateToUrl: () => {} }, chrome: { diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 9628a5cc61ba9..c8a8d877380e3 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -18,7 +18,7 @@ import { import { PluginContext } from '../context/plugin_context'; import { usePluginContext } from '../hooks/use_plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; -import { ObservabilityPluginSetupDeps } from '../plugin'; +import { ObservabilityPublicPluginsStart } from '../plugin'; import { HasDataContextProvider } from '../context/has_data_context'; import { Breadcrumbs, routes } from '../routes'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -68,7 +68,7 @@ function App() { export const renderApp = ( core: CoreStart, - plugins: ObservabilityPluginSetupDeps, + plugins: ObservabilityPublicPluginsStart, appMountParameters: AppMountParameters ) => { const { element, history } = appMountParameters; diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index 8f7961e13f80b..e5f100be285e1 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -14,7 +14,7 @@ import * as hasDataHook from '../../../../hooks/use_has_data'; import * as pluginContext from '../../../../hooks/use_plugin_context'; import { HasDataContextValue } from '../../../../context/has_data_context'; import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPluginSetupDeps } from '../../../../plugin'; +import { ObservabilityPublicPluginsStart } from '../../../../plugin'; jest.mock('react-router-dom', () => ({ useLocation: () => ({ @@ -53,7 +53,7 @@ describe('APMSection', () => { }, }, }, - } as unknown) as ObservabilityPluginSetupDeps, + } as unknown) as ObservabilityPublicPluginsStart, })); }); it('renders with transaction series and stats', () => { diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx index 3d0e1618d0c3e..d76e6d1b3e551 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx @@ -11,7 +11,7 @@ import { HasDataContextValue } from '../../../../context/has_data_context'; import * as fetcherHook from '../../../../hooks/use_fetcher'; import * as hasDataHook from '../../../../hooks/use_has_data'; import * as pluginContext from '../../../../hooks/use_plugin_context'; -import { ObservabilityPluginSetupDeps } from '../../../../plugin'; +import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { render } from '../../../../utils/test_helper'; import { UXSection } from './'; import { response } from './mock_data/ux.mock'; @@ -52,7 +52,7 @@ describe('UXSection', () => { }, }, }, - } as unknown) as ObservabilityPluginSetupDeps, + } as unknown) as ObservabilityPublicPluginsStart, })); }); it('renders with core web vitals', () => { diff --git a/x-pack/plugins/observability/public/context/plugin_context.tsx b/x-pack/plugins/observability/public/context/plugin_context.tsx index d47915feb7b48..771968861a6bb 100644 --- a/x-pack/plugins/observability/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability/public/context/plugin_context.tsx @@ -7,12 +7,12 @@ import { createContext } from 'react'; import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPluginSetupDeps } from '../plugin'; +import { ObservabilityPublicPluginsStart } from '../plugin'; export interface PluginContextValue { appMountParameters: AppMountParameters; core: CoreStart; - plugins: ObservabilityPluginSetupDeps; + plugins: ObservabilityPublicPluginsStart; } export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability/public/hooks/use_fetcher.tsx b/x-pack/plugins/observability/public/hooks/use_fetcher.tsx index 2a529f634e7a8..8e30f270bc58c 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetcher.tsx +++ b/x-pack/plugins/observability/public/hooks/use_fetcher.tsx @@ -18,6 +18,7 @@ export interface FetcherResult { data?: Data; status: FETCH_STATUS; error?: Error; + loading?: boolean; } // fetcher functions can return undefined OR a promise. Previously we had a more simple type @@ -38,6 +39,7 @@ export function useFetcher( const [result, setResult] = useState>>({ data: undefined, status: FETCH_STATUS.PENDING, + loading: true, }); const [counter, setCounter] = useState(0); useEffect(() => { @@ -51,6 +53,7 @@ export function useFetcher( data: preservePreviousData ? prevResult.data : undefined, status: FETCH_STATUS.LOADING, error: undefined, + loading: true, })); try { @@ -65,6 +68,7 @@ export function useFetcher( data: preservePreviousData ? prevResult.data : undefined, status: FETCH_STATUS.FAILURE, error: e, + loading: false, })); } } @@ -76,6 +80,7 @@ export function useFetcher( return useMemo(() => { return { ...result, + loading: result.status === FETCH_STATUS.LOADING || result.status === FETCH_STATUS.PENDING, refetch: () => { // this will invalidate the deps to `useEffect` and will result in a new request setCounter((count) => count + 1); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts index 7e065efbf2937..184ec4f3390f4 100644 --- a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts @@ -8,7 +8,7 @@ import { useTimeRange } from './use_time_range'; import * as pluginContext from './use_plugin_context'; import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPluginSetupDeps } from '../plugin'; +import { ObservabilityPublicPluginsStart } from '../plugin'; import * as kibanaUISettings from './use_kibana_ui_settings'; jest.mock('react-router-dom', () => ({ @@ -36,7 +36,7 @@ describe('useTimeRange', () => { }, }, }, - } as unknown) as ObservabilityPluginSetupDeps, + } as unknown) as ObservabilityPublicPluginsStart, })); jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ from: '2020-10-08T05:00:00.000Z', @@ -76,7 +76,7 @@ describe('useTimeRange', () => { }, }, }, - } as unknown) as ObservabilityPluginSetupDeps, + } as unknown) as ObservabilityPublicPluginsStart, })); }); it('returns ranges and absolute times from kibana default settings', () => { diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index dfe454ccc7b87..35443ca090077 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -6,12 +6,27 @@ */ import { PluginInitializerContext, PluginInitializer } from 'kibana/public'; -import { Plugin, ObservabilityPluginSetup, ObservabilityPluginStart } from './plugin'; -export type { ObservabilityPluginSetup, ObservabilityPluginStart }; - -export const plugin: PluginInitializer = ( - context: PluginInitializerContext -) => { +import { + Plugin, + ObservabilityPublicPluginsStart, + ObservabilityPublicPluginsSetup, + ObservabilityPublicStart, + ObservabilityPublicSetup, +} from './plugin'; +export type { + ObservabilityPublicSetup, + ObservabilityPublicStart, + ObservabilityPublicPluginsSetup, + ObservabilityPublicPluginsStart, +}; +export { enableInspectEsQueries } from '../common/ui_settings_keys'; + +export const plugin: PluginInitializer< + ObservabilityPublicSetup, + ObservabilityPublicStart, + ObservabilityPublicPluginsSetup, + ObservabilityPublicPluginsStart +> = (context: PluginInitializerContext) => { return new Plugin(context); }; diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index b5aaeea3367c1..56019eeccfd5a 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -15,7 +15,7 @@ import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { HasDataContextProvider } from '../../context/has_data_context'; import { PluginContext } from '../../context/plugin_context'; import { registerDataHandler, unregisterDataHandler } from '../../data_handler'; -import { ObservabilityPluginSetupDeps } from '../../plugin'; +import { ObservabilityPublicPluginsStart } from '../../plugin'; import { OverviewPage } from './'; import { alertsFetchData } from './mock/alerts.mock'; import { emptyResponse as emptyAPMResponse, fetchApmData } from './mock/apm.mock'; @@ -51,7 +51,7 @@ const withCore = makeDecorator({ timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, }, }, - } as unknown) as ObservabilityPluginSetupDeps, + } as unknown) as ObservabilityPublicPluginsStart, }} > diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 81c174932914b..cd3cb66187c6f 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -7,7 +7,7 @@ import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { DataPublicPluginSetup } from '../../../../src/plugins/data/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { AppMountParameters, AppUpdater, @@ -17,36 +17,53 @@ import { PluginInitializerContext, CoreStart, } from '../../../../src/core/public'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { HomePublicPluginSetup, HomePublicPluginStart } from '../../../../src/plugins/home/public'; import { registerDataHandler } from './data_handler'; import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; +import { LensPublicStart } from '../../lens/public'; -export interface ObservabilityPluginSetup { +export interface ObservabilityPublicSetup { dashboard: { register: typeof registerDataHandler }; } -export interface ObservabilityPluginSetupDeps { - home?: HomePublicPluginSetup; +export interface ObservabilityPublicPluginsSetup { data: DataPublicPluginSetup; + home?: HomePublicPluginSetup; +} + +export interface ObservabilityPublicPluginsStart { + home?: HomePublicPluginStart; + data: DataPublicPluginStart; + lens: LensPublicStart; } -export type ObservabilityPluginStart = void; +export type ObservabilityPublicStart = void; -export class Plugin implements PluginClass { +export class Plugin + implements + PluginClass< + ObservabilityPublicSetup, + ObservabilityPublicStart, + ObservabilityPublicPluginsSetup, + ObservabilityPublicPluginsStart + > { private readonly appUpdater$ = new BehaviorSubject(() => ({})); constructor(context: PluginInitializerContext) {} - public setup(core: CoreSetup, plugins: ObservabilityPluginSetupDeps) { + public setup( + core: CoreSetup, + plugins: ObservabilityPublicPluginsSetup + ) { const category = DEFAULT_APP_CATEGORIES.observability; - const euiIconType = 'logo-observability'; + const euiIconType = 'logoObservability'; const mount = async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); // Get start services - const [coreStart] = await core.getStartServices(); + const [coreStart, startPlugins] = await core.getStartServices(); - return renderApp(coreStart, plugins, params); + return renderApp(coreStart, startPlugins, params); }; const updater$ = this.appUpdater$; diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index b7dd70acb91bd..885303ea0c54b 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -13,7 +13,7 @@ import { of } from 'rxjs'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import translations from '../../../translations/translations/ja-JP.json'; import { PluginContext } from '../context/plugin_context'; -import { ObservabilityPluginSetupDeps } from '../plugin'; +import { ObservabilityPublicPluginsStart } from '../plugin'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; const appMountParameters = ({ setHeaderActionMenu: () => {} } as unknown) as AppMountParameters; @@ -32,7 +32,7 @@ export const core = ({ const plugins = ({ data: { query: { timefilter: { timefilter: { setTime: jest.fn() } } } }, -} as unknown) as ObservabilityPluginSetupDeps; +} as unknown) as ObservabilityPublicPluginsStart; export const render = (component: React.ReactNode) => { return testLibRender( diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index e118d17e17c3f..2676e40a4902f 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -8,7 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from 'src/core/server'; import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; -import { createOrUpdateIndex, MappingsDefinition } from './utils/create_or_update_index'; +import { createOrUpdateIndex, Mappings } from './utils/create_or_update_index'; import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; import { unwrapEsResponse, WrappedElasticsearchClientError } from './utils/unwrap_es_response'; @@ -29,7 +29,7 @@ export const plugin = (initContext: PluginInitializerContext) => export { createOrUpdateIndex, - MappingsDefinition, + Mappings, ObservabilityPluginSetup, ScopedAnnotationsClient, unwrapEsResponse, diff --git a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts index 7abd68cb9f16c..39a594dcc86ca 100644 --- a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts +++ b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts @@ -23,27 +23,6 @@ type CreateParams = t.TypeOf; type DeleteParams = t.TypeOf; type GetByIdParams = t.TypeOf; -interface IndexDocumentResponse { - _shards: { - total: number; - failed: number; - successful: number; - }; - _index: string; - _type: string; - _id: string; - _version: number; - _seq_no: number; - _primary_term: number; - result: string; -} - -export interface GetResponse { - _id: string; - _index: string; - _source: Annotation; -} - export function createAnnotationsClient(params: { index: string; esClient: ElasticsearchClient; @@ -95,7 +74,7 @@ export function createAnnotationsClient(params: { }; const body = await unwrapEsResponse( - esClient.index({ + esClient.index({ index, body: annotation, refresh: 'wait_for', @@ -103,18 +82,18 @@ export function createAnnotationsClient(params: { ); return ( - await esClient.get({ + await esClient.get({ index, id: body._id, }) - ).body; + ).body as { _id: string; _index: string; _source: Annotation }; } ), getById: ensureGoldLicense(async (getByIdParams: GetByIdParams) => { const { id } = getByIdParams; return unwrapEsResponse( - esClient.get({ + esClient.get({ id, index, }) diff --git a/x-pack/plugins/observability/server/lib/annotations/mappings.ts b/x-pack/plugins/observability/server/lib/annotations/mappings.ts index 3313c411b5889..da72afdbecb33 100644 --- a/x-pack/plugins/observability/server/lib/annotations/mappings.ts +++ b/x-pack/plugins/observability/server/lib/annotations/mappings.ts @@ -6,7 +6,7 @@ */ export const mappings = { - dynamic: 'strict', + dynamic: 'strict' as const, properties: { annotation: { properties: { @@ -45,4 +45,4 @@ export const mappings = { }, }, }, -} as const; +}; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 3123ce96114d7..43041280d0282 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { UiSettingsParams } from '../../../../src/core/types'; -import { enableAlertingExperience } from '../common/ui_settings_keys'; +import { enableAlertingExperience, enableInspectEsQueries } from '../common/ui_settings_keys'; /** * uiSettings definitions for Observability. @@ -29,4 +29,15 @@ export const uiSettings: Record> = { ), schema: schema.boolean(), }, + [enableInspectEsQueries]: { + category: ['observability'], + name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', { + defaultMessage: 'inspect ES queries', + }), + value: false, + description: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentDescription', { + defaultMessage: 'Inspect Elasticsearch queries in API responses.', + }), + schema: schema.boolean(), + }, }; diff --git a/x-pack/plugins/observability/server/utils/create_or_update_index.ts b/x-pack/plugins/observability/server/utils/create_or_update_index.ts index cc6504fd4d4fd..19b14ef8b2437 100644 --- a/x-pack/plugins/observability/server/utils/create_or_update_index.ts +++ b/x-pack/plugins/observability/server/utils/create_or_update_index.ts @@ -4,24 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { CreateIndexRequest, PutMappingRequest } from '@elastic/elasticsearch/api/types'; import pRetry from 'p-retry'; import { Logger, ElasticsearchClient } from 'src/core/server'; -export interface MappingsObject { - type: string; - ignore_above?: number; - scaling_factor?: number; - ignore_malformed?: boolean; - coerce?: boolean; - fields?: Record; -} - -export interface MappingsDefinition { - dynamic?: boolean | 'strict'; - properties: Record; - dynamic_templates?: any[]; -} +export type Mappings = Required['body']['mappings'] & + Required['body']; export async function createOrUpdateIndex({ index, @@ -30,7 +18,7 @@ export async function createOrUpdateIndex({ logger, }: { index: string; - mappings: MappingsDefinition; + mappings: Mappings; client: ElasticsearchClient; logger: Logger; }) { @@ -59,7 +47,8 @@ export async function createOrUpdateIndex({ }); if (!result.body.acknowledged) { - const resultError = result && result.body.error && JSON.stringify(result.body.error); + const bodyWithError: { body?: { error: any } } = result as any; + const resultError = JSON.stringify(bodyWithError?.body?.error); throw new Error(resultError); } }, @@ -82,9 +71,9 @@ function createNewIndex({ }: { index: string; client: ElasticsearchClient; - mappings: MappingsDefinition; + mappings: Required['body']['mappings']; }) { - return client.indices.create<{ acknowledged: boolean; error: any }>({ + return client.indices.create({ index, body: { // auto_expand_replicas: Allows cluster to not have replicas for this index @@ -101,9 +90,9 @@ function updateExistingIndex({ }: { index: string; client: ElasticsearchClient; - mappings: MappingsDefinition; + mappings: PutMappingRequest['body']; }) { - return client.indices.putMapping<{ acknowledged: boolean; error: any }>({ + return client.indices.putMapping({ index, body: mappings, }); diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 5c7528610a0b1..6833948b86b18 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts index 561866d5077a6..b24e4f28d89f1 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts @@ -5,15 +5,15 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import type { estypes } from '@elastic/elasticsearch'; import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; import { Inspect, Maybe, PageInfoPaginated } from '../../common'; import { RequestOptions, RequestOptionsPaginated } from '../..'; -export type ActionEdges = SearchResponse['hits']['hits']; +export type ActionEdges = estypes.SearchResponse['hits']['hits']; -export type ActionResultEdges = SearchResponse['hits']['hits']; +export type ActionResultEdges = estypes.SearchResponse['hits']['hits']; export interface ActionsStrategyResponse extends IEsSearchResponse { edges: ActionEdges; totalCount: number; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts index add8598bb77ad..035774aaffe36 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import { estypes } from '@elastic/elasticsearch'; import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; import { Inspect, Maybe, PageInfoPaginated } from '../../common'; import { RequestOptionsPaginated } from '../..'; -export type ResultEdges = SearchResponse['hits']['hits']; +export type ResultEdges = estypes.SearchResponse['hits']['hits']; export interface ResultsStrategyResponse extends IEsSearchResponse { edges: ResultEdges; diff --git a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx index 1dd5b63eedc23..1880cec0ae8e2 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx @@ -107,7 +107,7 @@ const ActionResultsTableComponent: React.FC = ({ action if (columnId === 'status') { // eslint-disable-next-line react-hooks/rules-of-hooks const linkProps = useRouterNavigate( - `/live_query/${actionId}/results/${value.fields.agent_id[0]}` + `/live_query/${actionId}/results/${value.fields?.agent_id[0]}` ); return ( @@ -122,7 +122,7 @@ const ActionResultsTableComponent: React.FC = ({ action // eslint-disable-next-line react-hooks/rules-of-hooks const { data: allResultsData } = useAllResults({ actionId, - agentId: value.fields.agent_id[0], + agentId: value.fields?.agent_id[0], activePage: pagination.pageIndex, limit: pagination.pageSize, direction: Direction.asc, @@ -133,7 +133,7 @@ const ActionResultsTableComponent: React.FC = ({ action } if (columnId === 'agent_status') { - const agentIdValue = value.fields.agent_id[0]; + const agentIdValue = value.fields?.agent_id[0]; // @ts-expect-error update types const agent = find(['_id', agentIdValue], agentsData?.agents); const online = agent?.active; @@ -143,7 +143,7 @@ const ActionResultsTableComponent: React.FC = ({ action } if (columnId === 'agent') { - const agentIdValue = value.fields.agent_id[0]; + const agentIdValue = value.fields?.agent_id[0]; // @ts-expect-error update types const agent = find(['_id', agentIdValue], agentsData?.agents); const agentName = agent?.local_metadata.host.name; @@ -156,6 +156,7 @@ const ActionResultsTableComponent: React.FC = ({ action } if (columnId === '@timestamp') { + // @ts-expect-error fields is optional return value.fields['@timestamp']; } diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index 986b46b1a4089..ca85693849651 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -62,6 +62,7 @@ const ActionsTableComponent = () => { () => ({ rowIndex, columnId }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const data = useContext(DataContext); + // @ts-expect-error fields is optional const value = data[rowIndex].fields[columnId]; if (columnId === 'action_id') { diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 4c2048148f745..7557828c4407c 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -68,9 +68,11 @@ const ResultsTableComponent: React.FC = ({ actionId, // eslint-disable-next-line react-hooks/rules-of-hooks const data = useContext(DataContext); + // @ts-expect-error fields is optional const value = data[rowIndex].fields[columnId]; if (columnId === 'agent.name') { + // @ts-expect-error fields is optional const agentIdValue = data[rowIndex].fields['agent.id']; // eslint-disable-next-line react-hooks/rules-of-hooks const linkProps = useRouterNavigate(`/live_query/${actionId}/results/${agentIdValue}`); diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts index bc0af9a1d0dcc..e05bd15bcc722 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts @@ -36,6 +36,7 @@ export const allActions: OsqueryFactory = { ...response, inspect, edges: response.rawResponse.hits.hits, + // @ts-expect-error doesn't handle case when total TotalHits totalCount: response.rawResponse.hits.total, pageInfo: { activePage: activePage ?? 0, diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts index dbcc03006399a..50fcf938bdd79 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts @@ -37,6 +37,7 @@ export const actionResults: OsqueryFactory = { ...response, inspect, edges: response.rawResponse.hits.hits, + // @ts-expect-error doesn't handle case when total TotalHits totalCount: response.rawResponse.hits.total, pageInfo: { activePage: activePage ?? 0, diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts index 1f7fbccb68682..7e0532dfec1af 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts @@ -37,7 +37,11 @@ export const allAgents: OsqueryFactory = { return { ...response, inspect, - edges: response.rawResponse.hits.hits.map((hit) => ({ _id: hit._id, ...hit._source })), + edges: response.rawResponse.hits.hits.map((hit) => ({ + _id: hit._id, + ...hit._source, + })) as Agent[], + // @ts-expect-error doesn't handle case when total TotalHits totalCount: response.rawResponse.hits.total, pageInfo: { activePage: activePage ?? 0, diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts index 7b9a24e4a0653..93cba882e39ed 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts @@ -36,6 +36,7 @@ export const allResults: OsqueryFactory = { ...response, inspect, edges: response.rawResponse.hits.hits, + // @ts-expect-error doesn't handle case when total TotalHits totalCount: response.rawResponse.hits.total, pageInfo: { activePage: activePage ?? 0, diff --git a/x-pack/plugins/painless_lab/server/routes/api/execute.ts b/x-pack/plugins/painless_lab/server/routes/api/execute.ts index e77f4fd4a05b5..0316809805200 100644 --- a/x-pack/plugins/painless_lab/server/routes/api/execute.ts +++ b/x-pack/plugins/painless_lab/server/routes/api/execute.ts @@ -27,6 +27,7 @@ export function registerExecuteRoute({ router, license }: RouteDependencies) { try { const client = ctx.core.elasticsearch.client.asCurrentUser; const response = await client.scriptsPainlessExecute({ + // @ts-expect-error `ExecutePainlessScriptRequest.body` does not allow `string` body, }); diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 370fc42921acf..85c5379a63b7f 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -91,7 +91,7 @@ export class CsvGenerator { }; const results = ( await this.clients.data.search(searchParams, { strategy: ES_SEARCH_STRATEGY }).toPromise() - ).rawResponse; + ).rawResponse as SearchResponse; return results; } diff --git a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts index 1eb1103336801..f07da188f3e62 100644 --- a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { get } from 'lodash'; -import { ElasticsearchClient, SearchResponse } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { ReportingConfig } from '../'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { GetLicense } from './'; @@ -19,7 +19,7 @@ import { KeyCountBucket, RangeStats, ReportingUsageType, - ReportingUsageSearchResponse, + // ReportingUsageSearchResponse, StatusByAppBucket, } from './types'; @@ -100,7 +100,8 @@ type RangeStatSets = Partial & { last7Days: Partial; }; -type ESResponse = Partial>; +// & ReportingUsageSearchResponse +type ESResponse = Partial; async function handleResponse(response: ESResponse): Promise> { const buckets = get(response, 'aggregations.ranges.buckets'); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx index f16655f048ed6..9767ee90fc14c 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx @@ -24,7 +24,10 @@ const setup = (props?: Props) => const docLinks: DocLinksStart = { ELASTIC_WEBSITE_URL: 'https://jestTest.elastic.co', DOC_LINK_VERSION: 'jest', - links: {} as any, + links: { + runtimeFields: { mapping: 'https://jestTest.elastic.co/to-be-defined.html' }, + scriptedFields: {} as any, + } as any, }; describe('Runtime field editor', () => { diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx index f8f276f1754ac..abcff4a79a475 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx @@ -23,7 +23,10 @@ const setup = (props?: Props) => const docLinks: DocLinksStart = { ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co', DOC_LINK_VERSION: 'jest', - links: {} as any, + links: { + runtimeFields: { mapping: 'https://jestTest.elastic.co/to-be-defined.html' }, + scriptedFields: {} as any, + } as any, }; const noop = () => {}; diff --git a/x-pack/plugins/runtime_fields/public/lib/documentation.ts b/x-pack/plugins/runtime_fields/public/lib/documentation.ts index 14f3e825d14ab..dfdd50b07d769 100644 --- a/x-pack/plugins/runtime_fields/public/lib/documentation.ts +++ b/x-pack/plugins/runtime_fields/public/lib/documentation.ts @@ -7,14 +7,11 @@ import { DocLinksStart } from 'src/core/public'; -export const getLinks = (docLinks: DocLinksStart) => { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`; - +export const getLinks = ({ links }: DocLinksStart) => { + const runtimePainless = `${links.runtimeFields.mapping}`; + const painlessSyntax = `${links.scriptedFields.painlessLangSpec}`; return { - runtimePainless: `${esDocsBase}/runtime.html#runtime-mapping-fields`, - painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`, + runtimePainless, + painlessSyntax, }; }; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts b/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts index 3e745458f6607..06230172d52bd 100644 --- a/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts +++ b/x-pack/plugins/saved_objects_tagging/server/usage/fetch_tag_usage_data.ts @@ -11,26 +11,22 @@ import { TaggingUsageData, ByTypeTaggingUsageData } from './types'; /** * Manual type reflection of the `tagDataAggregations` resulting payload */ -interface AggregatedTagUsageResponseBody { - aggregations: { - by_type: { - buckets: Array<{ - key: string; +interface AggregatedTagUsage { + buckets: Array<{ + key: string; + doc_count: number; + nested_ref: { + tag_references: { doc_count: number; - nested_ref: { - tag_references: { + tag_id: { + buckets: Array<{ + key: string; doc_count: number; - tag_id: { - buckets: Array<{ - key: string; - doc_count: number; - }>; - }; - }; + }>; }; - }>; + }; }; - }; + }>; } export const fetchTagUsageData = async ({ @@ -40,7 +36,7 @@ export const fetchTagUsageData = async ({ esClient: ElasticsearchClient; kibanaIndex: string; }): Promise => { - const { body } = await esClient.search({ + const { body } = await esClient.search({ index: [kibanaIndex], ignore_unavailable: true, filter_path: 'aggregations', @@ -59,7 +55,7 @@ export const fetchTagUsageData = async ({ const allUsedTags = new Set(); let totalTaggedObjects = 0; - const typeBuckets = body.aggregations.by_type.buckets; + const typeBuckets = (body.aggregations!.by_type as AggregatedTagUsage).buckets; typeBuckets.forEach((bucket) => { const type = bucket.key; const taggedDocCount = bucket.doc_count; diff --git a/x-pack/plugins/searchprofiler/server/routes/profile.ts b/x-pack/plugins/searchprofiler/server/routes/profile.ts index cdc420667f9e1..cbe0b75bc9eda 100644 --- a/x-pack/plugins/searchprofiler/server/routes/profile.ts +++ b/x-pack/plugins/searchprofiler/server/routes/profile.ts @@ -33,16 +33,15 @@ export const register = ({ router, getLicenseStatus, log }: RouteDependencies) = body: { query, index }, } = request; - const parsed = { - // Activate profiler mode for this query. - profile: true, - ...query, - }; - const body = { index, - body: JSON.stringify(parsed, null, 2), + body: { + // Activate profiler mode for this query. + profile: true, + ...query, + }, }; + try { const client = ctx.core.elasticsearch.client.asCurrentUser; const resp = await client.search(body); diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index 3715245b37e61..6dad3886401ac 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -7,7 +7,10 @@ import type { AuthenticatedUser } from './authenticated_user'; -export function mockAuthenticatedUser(user: Partial = {}) { +// We omit `roles` here since the original interface defines this field as `readonly string[]` that makes it hard to use +// in various mocks that expect mutable string array. +type AuthenticatedUserProps = Partial & { roles: string[] }>; +export function mockAuthenticatedUser(user: AuthenticatedUserProps = {}) { return { username: 'user', email: 'email', @@ -18,6 +21,7 @@ export function mockAuthenticatedUser(user: Partial = {}) { lookup_realm: { name: 'native1', type: 'native' }, authentication_provider: { type: 'basic', name: 'basic1' }, authentication_type: 'realm', + metadata: { _reserved: false }, ...user, }; } diff --git a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx index 2c40fec2ec31d..4c0bb6a67f2e4 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx @@ -53,7 +53,8 @@ describe('CreateUserPage', () => { }); }); - it('validates form', async () => { + // flaky https://github.com/elastic/kibana/issues/95345 + it.skip('validates form', async () => { const coreStart = coreMock.createStart(); const history = createMemoryHistory({ initialEntries: ['/create'] }); const authc = securityMock.createSetup().authc; diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts index dda3a15903696..a1b9671ab6bd7 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts @@ -64,7 +64,14 @@ describe('API Keys', () => { it('returns true when the operation completes without error', async () => { mockLicense.isEnabled.mockReturnValue(true); mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValue( - securityMock.createApiResponse({ body: {} }) + securityMock.createApiResponse({ + body: { + invalidated_api_keys: [], + previously_invalidated_api_keys: [], + error_count: 0, + error_details: [], + }, + }) ); const result = await apiKeys.areAPIKeysEnabled(); expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); @@ -105,7 +112,14 @@ describe('API Keys', () => { it('calls `invalidateApiKey` with proper parameters', async () => { mockLicense.isEnabled.mockReturnValue(true); mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValueOnce( - securityMock.createApiResponse({ body: {} }) + securityMock.createApiResponse({ + body: { + invalidated_api_keys: [], + previously_invalidated_api_keys: [], + error_count: 0, + error_details: [], + }, + }) ); const result = await apiKeys.areAPIKeysEnabled(); @@ -133,6 +147,7 @@ describe('API Keys', () => { mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.asCurrentUser.security.createApiKey.mockResolvedValueOnce( + // @ts-expect-error @elastic/elsticsearch CreateApiKeyResponse.expiration: number securityMock.createApiResponse({ body: { id: '123', @@ -302,6 +317,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }, }) ); @@ -312,6 +328,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }); expect(mockScopedClusterClient.asCurrentUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { @@ -328,6 +345,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }, }) ); @@ -339,6 +357,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }); expect(mockScopedClusterClient.asCurrentUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { @@ -364,6 +383,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }, }) ); @@ -372,6 +392,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }); expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { @@ -388,6 +409,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }, }) ); @@ -399,6 +421,7 @@ describe('API Keys', () => { invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, + error_details: [], }); expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index bdf549095c3c0..7396519acf9ea 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -109,7 +109,7 @@ export interface InvalidateAPIKeyResult { error_details?: Array<{ type: string; reason: string; - caused_by: { + caused_by?: { type: string; reason: string; }; @@ -145,7 +145,11 @@ export class APIKeys { ); try { - await this.clusterClient.asInternalUser.security.invalidateApiKey({ body: { ids: [id] } }); + await this.clusterClient.asInternalUser.security.invalidateApiKey({ + body: { + ids: [id], + }, + }); return true; } catch (e) { if (this.doesErrorIndicateAPIKeysAreDisabled(e)) { @@ -171,12 +175,12 @@ export class APIKeys { this.logger.debug('Trying to create an API key'); // User needs `manage_api_key` privilege to use this API - let result; + let result: CreateAPIKeyResult; try { result = ( await this.clusterClient .asScoped(request) - .asCurrentUser.security.createApiKey({ body: params }) + .asCurrentUser.security.createApiKey({ body: params }) ).body; this.logger.debug('API key was created successfully'); } catch (e) { @@ -207,10 +211,11 @@ export class APIKeys { const params = this.getGrantParams(createParams, authorizationHeader); // User needs `manage_api_key` or `grant_api_key` privilege to use this API - let result; + let result: GrantAPIKeyResult; try { result = ( - await this.clusterClient.asInternalUser.security.grantApiKey({ + await this.clusterClient.asInternalUser.security.grantApiKey({ + // @ts-expect-error @elastic/elasticsearch api_key.role_descriptors body: params, }) ).body; @@ -235,15 +240,15 @@ export class APIKeys { this.logger.debug(`Trying to invalidate ${params.ids.length} an API key as current user`); - let result; + let result: InvalidateAPIKeyResult; try { // User needs `manage_api_key` privilege to use this API result = ( - await this.clusterClient - .asScoped(request) - .asCurrentUser.security.invalidateApiKey({ - body: { ids: params.ids }, - }) + await this.clusterClient.asScoped(request).asCurrentUser.security.invalidateApiKey({ + body: { + ids: params.ids, + }, + }) ).body; this.logger.debug( `API keys by ids=[${params.ids.join(', ')}] was invalidated successfully as current user` @@ -271,12 +276,14 @@ export class APIKeys { this.logger.debug(`Trying to invalidate ${params.ids.length} API keys`); - let result; + let result: InvalidateAPIKeyResult; try { // Internal user needs `cluster:admin/xpack/security/api_key/invalidate` privilege to use this API result = ( - await this.clusterClient.asInternalUser.security.invalidateApiKey({ - body: { ids: params.ids }, + await this.clusterClient.asInternalUser.security.invalidateApiKey({ + body: { + ids: params.ids, + }, }) ).body; this.logger.debug(`API keys by ids=[${params.ids.join(', ')}] was invalidated successfully`); diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 20946ff6f5e80..18d567a143fee 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -113,10 +113,11 @@ export abstract class BaseAuthenticationProvider { */ protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { return this.authenticationInfoToAuthenticatedUser( + // @ts-expect-error @elastic/elasticsearch `AuthenticateResponse` type doesn't define `authentication_type` and `enabled`. ( await this.options.client .asScoped({ headers: { ...request.headers, ...authHeaders } }) - .asCurrentUser.security.authenticate() + .asCurrentUser.security.authenticate() ).body ); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 1bcb845ca5c08..f5c02953cebd3 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -46,7 +46,7 @@ describe('KerberosAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( - securityMock.createApiResponse({ body: {} }) + securityMock.createApiResponse({ body: mockAuthenticatedUser() }) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -122,6 +122,7 @@ describe('KerberosAuthenticationProvider', () => { }); mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + // @ts-expect-error not full interface securityMock.createApiResponse({ body: { access_token: 'some-token', @@ -156,6 +157,7 @@ describe('KerberosAuthenticationProvider', () => { }); mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + // @ts-expect-error not full interface securityMock.createApiResponse({ body: { access_token: 'some-token', diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 0de8e8e10a630..75dc2a8f47969 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -155,9 +155,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { authentication: AuthenticationInfo; }; try { + // @ts-expect-error authentication.email can be optional tokens = ( - await this.options.client.asInternalUser.security.getToken({ - body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, + await this.options.client.asInternalUser.security.getToken({ + body: { + grant_type: '_kerberos', + kerberos_ticket: kerberosTicket, + }, }) ).body; } catch (err) { diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index f35545e5e5f3a..ebeca42682eb9 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -11,7 +11,6 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; -import type { AuthenticatedUser } from '../../../common/model'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -24,7 +23,7 @@ import { OIDCAuthenticationProvider, OIDCLogin } from './oidc'; describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; - let mockUser: AuthenticatedUser; + let mockUser: ReturnType; let mockScopedClusterClient: ReturnType< typeof elasticsearchServiceMock.createScopedClusterClient >; diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 50d9ab33fd96f..bd51a0f815329 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -10,7 +10,6 @@ import Boom from '@hapi/boom'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; -import type { AuthenticatedUser } from '../../../common/model'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -22,7 +21,7 @@ import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; - let mockUser: AuthenticatedUser; + let mockUser: ReturnType; let mockScopedClusterClient: ReturnType< typeof elasticsearchServiceMock.createScopedClusterClient >; diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 4d80250607121..84a1649540267 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -50,6 +50,7 @@ describe('TokenAuthenticationProvider', () => { const authorization = `Bearer ${tokenPair.accessToken}`; mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + // @ts-expect-error not full interface securityMock.createApiResponse({ body: { access_token: tokenPair.accessToken, diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index f202a0bd43fcf..43338a8f6400f 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -72,16 +72,21 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { refresh_token: refreshToken, authentication: authenticationInfo, } = ( - await this.options.client.asInternalUser.security.getToken<{ - access_token: string; - refresh_token: string; - authentication: AuthenticationInfo; - }>({ body: { grant_type: 'password', username, password } }) + await this.options.client.asInternalUser.security.getToken({ + body: { + grant_type: 'password', + username, + password, + }, + }) ).body; this.logger.debug('Get token API request to Elasticsearch successful'); return AuthenticationResult.succeeded( - this.authenticationInfoToAuthenticatedUser(authenticationInfo), + this.authenticationInfoToAuthenticatedUser( + // @ts-expect-error @elastic/elasticsearch GetUserAccessTokenResponse declares authentication: string, but expected AuthenticatedUser + authenticationInfo as AuthenticationInfo + ), { authHeaders: { authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index a6d52e355b145..d02b368635e6f 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -109,6 +109,9 @@ describe('Tokens', () => { access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken, authentication: authenticationInfo, + type: 'Bearer', + expires_in: 1200, + scope: 'FULL', }, }) ); @@ -197,7 +200,14 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockElasticsearchClient.security.invalidateToken.mockResolvedValue( - securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + securityMock.createApiResponse({ + body: { + invalidated_tokens: 1, + previously_invalidated_tokens: 0, + error_count: 0, + error_details: [], + }, + }) ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); @@ -215,7 +225,14 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo' }; mockElasticsearchClient.security.invalidateToken.mockResolvedValue( - securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + securityMock.createApiResponse({ + body: { + invalidated_tokens: 1, + previously_invalidated_tokens: 0, + error_count: 0, + error_details: [], + }, + }) ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); @@ -230,7 +247,14 @@ describe('Tokens', () => { const tokenPair = { refreshToken: 'foo' }; mockElasticsearchClient.security.invalidateToken.mockResolvedValue( - securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + securityMock.createApiResponse({ + body: { + invalidated_tokens: 1, + previously_invalidated_tokens: 0, + error_count: 0, + error_details: [], + }, + }) ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); @@ -274,7 +298,14 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockElasticsearchClient.security.invalidateToken.mockResolvedValue( - securityMock.createApiResponse({ body: { invalidated_tokens: 5 } }) + securityMock.createApiResponse({ + body: { + invalidated_tokens: 5, + previously_invalidated_tokens: 0, + error_count: 0, + error_details: [], + }, + }) ); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index e3c4644775613..8f6dd9275e59c 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -60,16 +60,22 @@ export class Tokens { refresh_token: refreshToken, authentication: authenticationInfo, } = ( - await this.options.client.security.getToken<{ - access_token: string; - refresh_token: string; - authentication: AuthenticationInfo; - }>({ body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken } }) + await this.options.client.security.getToken({ + body: { + grant_type: 'refresh_token', + refresh_token: existingRefreshToken, + }, + }) ).body; this.logger.debug('Access token has been successfully refreshed.'); - return { accessToken, refreshToken, authenticationInfo }; + return { + accessToken, + refreshToken, + // @ts-expect-error @elastic/elasticsearch decalred GetUserAccessTokenResponse.authentication: string + authenticationInfo: authenticationInfo as AuthenticationInfo, + }; } catch (err) { this.logger.debug(`Failed to refresh access token: ${err.message}`); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index 2c1deed0f8c30..75c8229bb37d6 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -68,8 +68,8 @@ describe('#atSpace', () => { const expectedIndexPrivilegePayload = Object.entries( options.elasticsearchPrivileges?.index ?? {} - ).map(([names, indexPrivileges]) => ({ - names, + ).map(([name, indexPrivileges]) => ({ + names: [name], privileges: indexPrivileges, })); @@ -78,7 +78,7 @@ describe('#atSpace', () => { body: { cluster: options.elasticsearchPrivileges?.cluster, index: expectedIndexPrivilegePayload, - applications: [ + application: [ { application, resources: [`space:${options.spaceId}`], @@ -914,8 +914,8 @@ describe('#atSpaces', () => { const expectedIndexPrivilegePayload = Object.entries( options.elasticsearchPrivileges?.index ?? {} - ).map(([names, indexPrivileges]) => ({ - names, + ).map(([name, indexPrivileges]) => ({ + names: [name], privileges: indexPrivileges, })); @@ -924,7 +924,7 @@ describe('#atSpaces', () => { body: { cluster: options.elasticsearchPrivileges?.cluster, index: expectedIndexPrivilegePayload, - applications: [ + application: [ { application, resources: options.spaceIds.map((spaceId) => `space:${spaceId}`), @@ -2118,8 +2118,8 @@ describe('#globally', () => { const expectedIndexPrivilegePayload = Object.entries( options.elasticsearchPrivileges?.index ?? {} - ).map(([names, indexPrivileges]) => ({ - names, + ).map(([name, indexPrivileges]) => ({ + names: [name], privileges: indexPrivileges, })); @@ -2128,7 +2128,7 @@ describe('#globally', () => { body: { cluster: options.elasticsearchPrivileges?.cluster, index: expectedIndexPrivilegePayload, - applications: [ + application: [ { application, resources: [GLOBAL_RESOURCE], diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 0fc11cddf9bbc..3a35cf164ad85 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -51,22 +51,22 @@ export function checkPrivilegesWithRequestFactory( const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]); const clusterClient = await getClusterClient(); - const { body: hasPrivilegesResponse } = await clusterClient - .asScoped(request) - .asCurrentUser.security.hasPrivileges({ - body: { - cluster: privileges.elasticsearch?.cluster, - index: Object.entries(privileges.elasticsearch?.index ?? {}).map( - ([names, indexPrivileges]) => ({ - names, - privileges: indexPrivileges, - }) - ), - applications: [ - { application: applicationName, resources, privileges: allApplicationPrivileges }, - ], - }, - }); + const { body } = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({ + body: { + cluster: privileges.elasticsearch?.cluster, + index: Object.entries(privileges.elasticsearch?.index ?? {}).map( + ([name, indexPrivileges]) => ({ + names: [name], + privileges: indexPrivileges, + }) + ), + application: [ + { application: applicationName, resources, privileges: allApplicationPrivileges }, + ], + }, + }); + + const hasPrivilegesResponse: HasPrivilegesResponse = body; validateEsPrivilegeResponse( hasPrivilegesResponse, diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts index e2539695d54ee..e3a586062ae4c 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts @@ -6,7 +6,6 @@ */ import type { RouteDefinitionParams } from '../..'; -import type { BuiltinESPrivileges } from '../../../../common/model'; export function defineGetBuiltinPrivilegesRoutes({ router }: RouteDefinitionParams) { router.get( @@ -14,7 +13,7 @@ export function defineGetBuiltinPrivilegesRoutes({ router }: RouteDefinitionPara async (context, request, response) => { const { body: privileges, - } = await context.core.elasticsearch.client.asCurrentUser.security.getBuiltinPrivileges(); + } = await context.core.elasticsearch.client.asCurrentUser.security.getBuiltinPrivileges(); // Exclude the `none` privilege, as it doesn't make sense as an option within the Kibana UI privileges.cluster = privileges.cluster.filter((privilege) => privilege !== 'none'); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 3bfc5e5d4dda3..01d32f7fb8233 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -10,7 +10,6 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '../..'; import { wrapIntoCustomErrorResponse } from '../../../errors'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import type { ElasticsearchRole } from './model'; import { transformElasticsearchRoleToRole } from './model'; export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { @@ -25,14 +24,15 @@ export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { try { const { body: elasticsearchRoles, - } = await context.core.elasticsearch.client.asCurrentUser.security.getRole< - Record - >({ name: request.params.name }); + } = await context.core.elasticsearch.client.asCurrentUser.security.getRole({ + name: request.params.name, + }); const elasticsearchRole = elasticsearchRoles[request.params.name]; if (elasticsearchRole) { return response.ok({ body: transformElasticsearchRoleToRole( + // @ts-expect-error @elastic/elasticsearch `XPackRole` type doesn't define `applications` and `transient_metadata`. elasticsearchRole, request.params.name, authz.applicationName diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index 2994afd40f880..4d458be4e332f 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -26,7 +26,12 @@ export function defineGetAllRolesRoutes({ router, authz }: RouteDefinitionParams return response.ok({ body: Object.entries(elasticsearchRoles) .map(([roleName, elasticsearchRole]) => - transformElasticsearchRoleToRole(elasticsearchRole, roleName, authz.applicationName) + transformElasticsearchRoleToRole( + // @ts-expect-error @elastic/elasticsearch `XPackRole` type doesn't define `applications` and `transient_metadata`. + elasticsearchRole, + roleName, + authz.applicationName + ) ) .sort((roleA, roleB) => { if (roleA.name < roleB.name) { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts index f033b66805067..74a035cdd0cb6 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts @@ -25,7 +25,7 @@ export type ElasticsearchRole = Pick, name: string, application: string ): Role { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index aefcc0c72c6db..09bcb6b8c505c 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -12,7 +12,6 @@ import type { KibanaFeature } from '../../../../../features/common'; import { wrapIntoCustomErrorResponse } from '../../../errors'; import type { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import type { ElasticsearchRole } from './model'; import { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './model'; const roleGrantsSubFeaturePrivileges = ( @@ -65,13 +64,15 @@ export function definePutRolesRoutes({ try { const { body: rawRoles, - } = await context.core.elasticsearch.client.asCurrentUser.security.getRole< - Record - >({ name: request.params.name }, { ignore: [404] }); + } = await context.core.elasticsearch.client.asCurrentUser.security.getRole( + { name: request.params.name }, + { ignore: [404] } + ); const body = transformPutPayloadToElasticsearchRole( request.body, authz.applicationName, + // @ts-expect-error @elastic/elasticsearch `XPackRole` type doesn't define `applications`. rawRoles[name] ? rawRoles[name].applications : [] ); @@ -79,6 +80,7 @@ export function definePutRolesRoutes({ getFeatures(), context.core.elasticsearch.client.asCurrentUser.security.putRole({ name: request.params.name, + // @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security body, }), ]); diff --git a/x-pack/plugins/security/server/routes/indices/get_fields.ts b/x-pack/plugins/security/server/routes/indices/get_fields.ts index 3ed7493ea1f0e..63704682e3635 100644 --- a/x-pack/plugins/security/server/routes/indices/get_fields.ts +++ b/x-pack/plugins/security/server/routes/indices/get_fields.ts @@ -10,20 +10,6 @@ import { schema } from '@kbn/config-schema'; import { wrapIntoCustomErrorResponse } from '../../errors'; import type { RouteDefinitionParams } from '../index'; -interface FieldMappingResponse { - [indexName: string]: { - mappings: { - [fieldName: string]: { - mapping: { - [fieldName: string]: { - type: string; - }; - }; - }; - }; - }; -} - export function defineGetFieldsRoutes({ router }: RouteDefinitionParams) { router.get( { @@ -34,14 +20,12 @@ export function defineGetFieldsRoutes({ router }: RouteDefinitionParams) { try { const { body: indexMappings, - } = await context.core.elasticsearch.client.asCurrentUser.indices.getFieldMapping( - { - index: request.params.query, - fields: '*', - allow_no_indices: false, - include_defaults: true, - } - ); + } = await context.core.elasticsearch.client.asCurrentUser.indices.getFieldMapping({ + index: request.params.query, + fields: '*', + allow_no_indices: false, + include_defaults: true, + }); // The flow is the following (see response format at https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html): // 1. Iterate over all matched indices. @@ -52,7 +36,13 @@ export function defineGetFieldsRoutes({ router }: RouteDefinitionParams) { new Set( Object.values(indexMappings).flatMap((indexMapping) => { return Object.keys(indexMapping.mappings).filter((fieldName) => { - const mappingValues = Object.values(indexMapping.mappings[fieldName].mapping); + const mappingValues = Object.values( + // `FieldMapping` type from `TypeFieldMappings` --> `GetFieldMappingResponse` is not correct and + // doesn't have any properties. + (indexMapping.mappings[fieldName] as { + mapping: Record; + }).mapping + ); const hasMapping = mappingValues.length > 0; const isRuntimeField = hasMapping && mappingValues[0]?.type === 'runtime'; diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts index d060825a989d5..67cd8975b76eb 100644 --- a/x-pack/plugins/security/server/routes/role_mapping/get.ts +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -12,10 +12,6 @@ import type { RoleMapping } from '../../../common/model'; import { wrapError } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -interface RoleMappingsResponse { - [roleMappingName: string]: Omit; -} - export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { const { logger, router } = params; @@ -32,7 +28,7 @@ export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { const expectSingleEntity = typeof request.params.name === 'string'; try { - const roleMappingsResponse = await context.core.elasticsearch.client.asCurrentUser.security.getRoleMapping( + const roleMappingsResponse = await context.core.elasticsearch.client.asCurrentUser.security.getRoleMapping( { name: request.params.name } ); @@ -40,7 +36,8 @@ export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { return { name, ...mapping, - role_templates: (mapping.role_templates || []).map((entry) => { + // @ts-expect-error @elastic/elasticsearch `XPackRoleMapping` type doesn't define `role_templates` property. + role_templates: (mapping.role_templates || []).map((entry: RoleTemplate) => { return { ...entry, template: tryParseRoleTemplate(entry.template as string), diff --git a/x-pack/plugins/security/server/routes/users/get.ts b/x-pack/plugins/security/server/routes/users/get.ts index 28165ef32356d..81502044ef94d 100644 --- a/x-pack/plugins/security/server/routes/users/get.ts +++ b/x-pack/plugins/security/server/routes/users/get.ts @@ -24,9 +24,7 @@ export function defineGetUserRoutes({ router }: RouteDefinitionParams) { const username = request.params.username; const { body: users, - } = await context.core.elasticsearch.client.asCurrentUser.security.getUser< - Record - >({ + } = await context.core.elasticsearch.client.asCurrentUser.security.getUser({ username, }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 803b36e520a2f..554244dc98be9 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -1058,6 +1058,36 @@ describe('#closePointInTime', () => { }); }); +describe('#createPointInTimeFinder', () => { + it('redirects request to underlying base client with default dependencies', () => { + const options = { type: ['a', 'b'], search: 'query' }; + client.createPointInTimeFinder(options); + + expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, { + client, + }); + }); + + it('redirects request to underlying base client with custom dependencies', () => { + const options = { type: ['a', 'b'], search: 'query' }; + const dependencies = { + client: { + find: jest.fn(), + openPointInTimeForType: jest.fn(), + closePointInTime: jest.fn(), + }, + }; + client.createPointInTimeFinder(options, dependencies); + + expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledWith( + options, + dependencies + ); + }); +}); + describe('#resolve', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 1858bc7108dc9..8378cc4d848cf 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -16,6 +16,8 @@ import type { SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, SavedObjectsCreateOptions, + SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, SavedObjectsOpenPointInTimeOptions, @@ -616,6 +618,20 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.closePointInTime(id, options); } + public createPointInTimeFinder( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ) { + // We don't need to perform an authorization check here or add an audit log, because + // `createPointInTimeFinder` is simply a helper that calls `find`, `openPointInTimeForType`, + // and `closePointInTime` internally, so authz checks and audit logs will already be applied. + return this.baseClient.createPointInTimeFinder(findOptions, { + client: this, + // Include dependencies last so that subsequent SO client wrappers have their settings applied. + ...dependencies, + }); + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index b5b4f64438902..11fb4ca27f590 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -41,7 +41,7 @@ describe('Session index', () => { name: indexTemplateName, }); expect(mockElasticsearchClient.indices.exists).toHaveBeenCalledWith({ - index: getSessionIndexTemplate(indexName).index_patterns, + index: getSessionIndexTemplate(indexName).index_patterns[0], }); } @@ -93,7 +93,7 @@ describe('Session index', () => { body: expectedIndexTemplate, }); expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ - index: expectedIndexTemplate.index_patterns, + index: expectedIndexTemplate.index_patterns[0], }); }); @@ -126,7 +126,7 @@ describe('Session index', () => { assertExistenceChecksPerformed(); expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ - index: getSessionIndexTemplate(indexName).index_patterns, + index: getSessionIndexTemplate(indexName).index_patterns[0], }); }); @@ -166,7 +166,7 @@ describe('Session index', () => { const now = 123456; beforeEach(() => { mockElasticsearchClient.deleteByQuery.mockResolvedValue( - securityMock.createApiResponse({ body: {} }) + securityMock.createApiResponse({ body: {} as any }) ); jest.spyOn(Date, 'now').mockImplementation(() => now); }); @@ -600,7 +600,10 @@ describe('Session index', () => { it('returns `null` if index is not found', async () => { mockElasticsearchClient.get.mockResolvedValue( - securityMock.createApiResponse({ statusCode: 404, body: { status: 404 } }) + securityMock.createApiResponse({ + statusCode: 404, + body: { _index: 'my-index', _type: '_doc', _id: '0', found: false }, + }) ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); @@ -608,7 +611,10 @@ describe('Session index', () => { it('returns `null` if session index value document is not found', async () => { mockElasticsearchClient.get.mockResolvedValue( - securityMock.createApiResponse({ body: { status: 200, found: false } }) + securityMock.createApiResponse({ + statusCode: 200, + body: { _index: 'my-index', _type: '_doc', _id: '0', found: false }, + }) ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); @@ -625,9 +631,12 @@ describe('Session index', () => { mockElasticsearchClient.get.mockResolvedValue( securityMock.createApiResponse({ + statusCode: 200, body: { found: true, - status: 200, + _index: 'my-index', + _type: '_doc', + _id: '0', _source: indexDocumentSource, _primary_term: 1, _seq_no: 456, @@ -670,7 +679,17 @@ describe('Session index', () => { it('properly stores session value in the index', async () => { mockElasticsearchClient.create.mockResolvedValue( - securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654 } }) + securityMock.createApiResponse({ + body: { + _shards: { total: 1, failed: 0, successful: 1, skipped: 0 }, + _index: 'my-index', + _id: 'W0tpsmIBdwcYyG50zbta', + _version: 1, + _primary_term: 321, + _seq_no: 654, + result: 'created', + }, + }) ); const sid = 'some-long-sid'; @@ -708,7 +727,7 @@ describe('Session index', () => { await expect(sessionIndex.update(sessionIndexMock.createValue())).rejects.toBe(failureReason); }); - it('refetches latest session value if update fails due to conflict', async () => { + it('re-fetches latest session value if update fails due to conflict', async () => { const latestSessionValue = { usernameHash: 'some-username-hash', provider: { type: 'basic', name: 'basic1' }, @@ -719,17 +738,31 @@ describe('Session index', () => { mockElasticsearchClient.get.mockResolvedValue( securityMock.createApiResponse({ + statusCode: 200, body: { - found: true, - status: 200, + _index: 'my-index', + _type: '_doc', + _id: '0', _source: latestSessionValue, _primary_term: 321, _seq_no: 654, + found: true, }, }) ); mockElasticsearchClient.index.mockResolvedValue( - securityMock.createApiResponse({ statusCode: 409, body: { status: 409 } }) + securityMock.createApiResponse({ + statusCode: 409, + body: { + _shards: { total: 1, failed: 0, successful: 1, skipped: 0 }, + _index: 'my-index', + _id: 'W0tpsmIBdwcYyG50zbta', + _version: 1, + _primary_term: 321, + _seq_no: 654, + result: 'updated', + }, + }) ); const sid = 'some-long-sid'; @@ -763,7 +796,18 @@ describe('Session index', () => { it('properly stores session value in the index', async () => { mockElasticsearchClient.index.mockResolvedValue( - securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654, status: 200 } }) + securityMock.createApiResponse({ + statusCode: 200, + body: { + _shards: { total: 1, failed: 0, successful: 1, skipped: 0 }, + _index: 'my-index', + _id: 'W0tpsmIBdwcYyG50zbta', + _version: 1, + _primary_term: 321, + _seq_no: 654, + result: 'created', + }, + }) ); const sid = 'some-long-sid'; diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 1b5c820ec4710..d7a4c3e2520bf 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -39,7 +39,7 @@ const SESSION_INDEX_TEMPLATE_VERSION = 1; */ export function getSessionIndexTemplate(indexName: string) { return Object.freeze({ - index_patterns: indexName, + index_patterns: [indexName], order: 1000, settings: { index: { @@ -52,7 +52,7 @@ export function getSessionIndexTemplate(indexName: string) { }, }, mappings: { - dynamic: 'strict', + dynamic: 'strict' as 'strict', properties: { usernameHash: { type: 'keyword' }, provider: { properties: { name: { type: 'keyword' }, type: { type: 'keyword' } } }, @@ -151,13 +151,16 @@ export class SessionIndex { */ async get(sid: string) { try { - const { body: response } = await this.options.elasticsearchClient.get( + const { + body: response, + statusCode, + } = await this.options.elasticsearchClient.get( { id: sid, index: this.indexName }, { ignore: [404] } ); const docNotFound = response.found === false; - const indexNotFound = response.status === 404; + const indexNotFound = statusCode === 404; if (docNotFound || indexNotFound) { this.options.logger.debug('Cannot find session value with the specified ID.'); return null; @@ -215,7 +218,7 @@ export class SessionIndex { async update(sessionValue: Readonly) { const { sid, metadata, ...sessionValueToStore } = sessionValue; try { - const { body: response } = await this.options.elasticsearchClient.index( + const { body: response, statusCode } = await this.options.elasticsearchClient.index( { id: sid, index: this.indexName, @@ -230,7 +233,7 @@ export class SessionIndex { // We don't want to override changes that were made after we fetched session value or // re-create it if has been deleted already. If we detect such a case we discard changes and // return latest copy of the session value instead or `null` if doesn't exist anymore. - const sessionIndexValueUpdateConflict = response.status === 409; + const sessionIndexValueUpdateConflict = statusCode === 409; if (sessionIndexValueUpdateConflict) { this.options.logger.debug( 'Cannot update session value due to conflict, session either does not exist or was already updated.' @@ -457,7 +460,7 @@ export class SessionIndex { { ignore: [409, 404] } ); - if (response.deleted > 0) { + if (response.deleted! > 0) { this.options.logger.debug( `Cleaned up ${response.deleted} invalid or expired session values.` ); diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 143384d160471..4c62179f9ed54 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { ENABLE_CASE_CONNECTOR } from '../../cases/common/constants'; + export const APP_ID = 'securitySolution'; export const SERVER_APP_ID = 'siem'; export const APP_NAME = 'Security'; @@ -171,7 +173,6 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; /* Rule notifications options */ -export const ENABLE_CASE_CONNECTOR = true; export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', '.slack', diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 8234c3a9a599d..70fe2b6187aa6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -22,7 +22,7 @@ import { Query, Language, Index, TimestampOverrideOrUndefined } from './schemas/ export const getQueryFilter = ( query: Query, language: Language, - filters: Array>, + filters: unknown, index: Index, lists: Array, excludeExceptions: boolean = true @@ -48,7 +48,7 @@ export const getQueryFilter = ( chunkSize: 1024, }); const initialQuery = { query, language }; - const allFilters = getAllFilters((filters as unknown) as Filter[], exceptionFilter); + const allFilters = getAllFilters(filters as Filter[], exceptionFilter); return buildEsQuery(indexPattern, initialQuery, allFilters, config); }; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 90e025de1dcc8..d9f67e31196ca 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,8 +15,10 @@ 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 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'; diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index cf2b234451f50..b35504fc88659 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -7,6 +7,7 @@ import { Client } from '@elastic/elasticsearch'; import seedrandom from 'seedrandom'; +// eslint-disable-next-line import/no-extraneous-dependencies import { KbnClient } from '@kbn/test'; import { AxiosResponse } from 'axios'; import { EndpointDocGenerator, TreeOptions, Event } from './generate_data'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index e9ae439d0ac8c..326795ae55662 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -5,8 +5,18 @@ * 2.0. */ -import { GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema } from './trusted_apps'; -import { ConditionEntryField, OperatingSystem } from '../types'; +import { + GetTrustedAppsRequestSchema, + PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, +} from './trusted_apps'; +import { + ConditionEntry, + ConditionEntryField, + NewTrustedApp, + OperatingSystem, + PutTrustedAppsRequestParams, +} from '../types'; describe('When invoking Trusted Apps Schema', () => { describe('for GET List', () => { @@ -72,17 +82,18 @@ describe('When invoking Trusted Apps Schema', () => { }); describe('for POST Create', () => { - const createConditionEntry = (data?: T) => ({ + const createConditionEntry = (data?: T): ConditionEntry => ({ field: ConditionEntryField.PATH, type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', ...(data || {}), }); - const createNewTrustedApp = (data?: T) => ({ + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ name: 'Some Anti-Virus App', description: 'this one is ok', - os: 'windows', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, entries: [createConditionEntry()], ...(data || {}), }); @@ -329,4 +340,55 @@ describe('When invoking Trusted Apps Schema', () => { }); }); }); + + describe('for PUT Update', () => { + const createConditionEntry = (data?: T): ConditionEntry => ({ + field: ConditionEntryField.PATH, + type: 'match', + operator: 'included', + value: 'c:/programs files/Anti-Virus', + ...(data || {}), + }); + const createNewTrustedApp = (data?: T): NewTrustedApp => ({ + name: 'Some Anti-Virus App', + description: 'this one is ok', + os: OperatingSystem.WINDOWS, + effectScope: { type: 'global' }, + entries: [createConditionEntry()], + ...(data || {}), + }); + + const updateParams = (data?: T): PutTrustedAppsRequestParams => ({ + id: 'validId', + ...(data || {}), + }); + + const body = PutTrustedAppUpdateRequestSchema.body; + const params = PutTrustedAppUpdateRequestSchema.params; + + it('should not error on a valid message', () => { + const bodyMsg = createNewTrustedApp(); + const paramsMsg = updateParams(); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + expect(params.validate(paramsMsg)).toStrictEqual(paramsMsg); + }); + + it('should validate `id` params is required', () => { + expect(() => params.validate(updateParams({ id: undefined }))).toThrow(); + }); + + it('should validate `id` params to be string', () => { + expect(() => params.validate(updateParams({ id: 1 }))).toThrow(); + }); + + it('should validate `version`', () => { + const bodyMsg = createNewTrustedApp({ version: 'v1' }); + expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); + }); + + it('should validate `version` must be string', () => { + const bodyMsg = createNewTrustedApp({ version: 1 }); + expect(() => body.validate(bodyMsg)).toThrow(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 6d40dc75fd1c1..e582744e1a141 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -6,8 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import { ConditionEntryField, OperatingSystem } from '../types'; -import { getDuplicateFields, isValidHash } from '../validation/trusted_apps'; +import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; +import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; export const DeleteTrustedAppsRequestSchema = { params: schema.object({ @@ -15,10 +15,17 @@ export const DeleteTrustedAppsRequestSchema = { }), }; +export const GetOneTrustedAppRequestSchema = { + params: schema.object({ + id: schema.string(), + }), +}; + export const GetTrustedAppsRequestSchema = { query: schema.object({ page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), + kuery: schema.maybe(schema.string()), }), }; @@ -40,18 +47,18 @@ const CommonEntrySchema = { schema.siblingRef('field'), ConditionEntryField.HASH, schema.string({ - validate: (hash) => + validate: (hash: string) => isValidHash(hash) ? undefined : `invalidField.${ConditionEntryField.HASH}`, }), schema.conditional( schema.siblingRef('field'), ConditionEntryField.PATH, schema.string({ - validate: (field) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.PATH}`, }), schema.string({ - validate: (field) => + validate: (field: string) => field.length > 0 ? undefined : `invalidField.${ConditionEntryField.SIGNER}`, }) ) @@ -99,7 +106,7 @@ const EntrySchemaDependingOnOS = schema.conditional( */ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { minSize: 1, - validate(entries) { + validate(entries: ConditionEntry[]) { return ( getDuplicateFields(entries) .map((field) => `duplicatedEntry.${field}`) @@ -108,8 +115,8 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, { }, }); -export const PostTrustedAppCreateRequestSchema = { - body: schema.object({ +const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) => + schema.object({ name: schema.string({ minLength: 1, maxLength: 256 }), description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), os: schema.oneOf([ @@ -117,6 +124,26 @@ export const PostTrustedAppCreateRequestSchema = { schema.literal(OperatingSystem.LINUX), schema.literal(OperatingSystem.MAC), ]), + effectScope: schema.oneOf([ + schema.object({ + type: schema.literal('global'), + }), + schema.object({ + type: schema.literal('policy'), + policies: schema.arrayOf(schema.string({ minLength: 1 })), + }), + ]), entries: EntriesSchema, + ...(forUpdateFlow ? { version: schema.maybe(schema.string()) } : {}), + }); + +export const PostTrustedAppCreateRequestSchema = { + body: getTrustedAppForOsScheme(), +}; + +export const PutTrustedAppUpdateRequestSchema = { + params: schema.object({ + id: schema.string(), }), + body: getTrustedAppForOsScheme(true), }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.ts new file mode 100644 index 0000000000000..fcde1d44b682d --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/to_update_trusted_app.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MaybeImmutable, NewTrustedApp, UpdateTrustedApp } from '../../types'; + +const NEW_TRUSTED_APP_KEYS: Array = [ + 'name', + 'effectScope', + 'entries', + 'description', + 'os', + 'version', +]; + +export const toUpdateTrustedApp = ( + trustedApp: MaybeImmutable +): UpdateTrustedApp => { + const trustedAppForUpdate: UpdateTrustedApp = {} as UpdateTrustedApp; + + for (const key of NEW_TRUSTED_APP_KEYS) { + // This should be safe. Its needed due to the inter-dependency on property values (`os` <=> `entries`) + // @ts-expect-error + trustedAppForUpdate[key] = trustedApp[key]; + } + return trustedAppForUpdate; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts similarity index 93% rename from x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts rename to x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index faad639eeacb3..b0828be6af6c5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/validation/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConditionEntry, ConditionEntryField } from '../types'; +import { ConditionEntry, ConditionEntryField } from '../../types'; const HASH_LENGTHS: readonly number[] = [ 32, // MD5 diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 87268f02a16e1..bed9c2880440a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -62,6 +62,11 @@ type ImmutableMap = ReadonlyMap, Immutable>; type ImmutableSet = ReadonlySet>; type ImmutableObject = { readonly [K in keyof T]: Immutable }; +/** + * Utility type that will return back a union of the given [T]ype and an Immutable version of it + */ +export type MaybeImmutable = T | Immutable; + /** * Stats for related events for a particular node in a resolver graph. */ @@ -375,12 +380,12 @@ export enum HostStatus { * Default state of the host when no host information is present or host information cannot * be retrieved. e.g. API error */ - ERROR = 'error', + UNHEALTHY = 'unhealthy', /** * Host is online as indicated by its checkin status during the last checkin window */ - ONLINE = 'online', + HEALTHY = 'healthy', /** * Host is offline as indicated by its checkin status during the last checkin window @@ -388,9 +393,14 @@ export enum HostStatus { OFFLINE = 'offline', /** - * Host is unenrolling as indicated by its checkin status during the last checkin window + * Host is unenrolling, enrolling or updating as indicated by its checkin status during the last checkin window + */ + UPDATING = 'updating', + + /** + * Host is inactive as indicated by its checkin status during the last checkin window */ - UNENROLLING = 'unenrolling', + INACTIVE = 'inactive', } export enum MetadataQueryStrategyVersions { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index a5c3c1eab52b3..d36958c11d2a1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -9,14 +9,22 @@ import { TypeOf } from '@kbn/config-schema'; import { ApplicationStart } from 'kibana/public'; import { DeleteTrustedAppsRequestSchema, + GetOneTrustedAppRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, + PutTrustedAppUpdateRequestSchema, } from '../schema/trusted_apps'; import { OperatingSystem } from './os'; /** API request params for deleting Trusted App entry */ export type DeleteTrustedAppsRequestParams = TypeOf; +export type GetOneTrustedAppRequestParams = TypeOf; + +export interface GetOneTrustedAppResponse { + data: TrustedApp; +} + /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; @@ -39,6 +47,15 @@ export interface PostTrustedAppCreateResponse { data: TrustedApp; } +/** API request params for updating a Trusted App */ +export type PutTrustedAppsRequestParams = TypeOf; + +/** API Request body for Updating a new Trusted App entry */ +export type PutTrustedAppUpdateRequest = TypeOf & + (MacosLinuxConditionEntries | WindowsConditionEntries); + +export type PutTrustedAppUpdateResponse = PostTrustedAppCreateResponse; + export interface GetTrustedAppsSummaryResponse { total: number; windows: number; @@ -76,17 +93,38 @@ export interface WindowsConditionEntries { entries: WindowsConditionEntry[]; } +export interface GlobalEffectScope { + type: 'global'; +} + +export interface PolicyEffectScope { + type: 'policy'; + /** An array of Endpoint Integration Policy UUIDs */ + policies: string[]; +} + +export type EffectScope = GlobalEffectScope | PolicyEffectScope; + /** Type for a new Trusted App Entry */ export type NewTrustedApp = { name: string; description?: string; + effectScope: EffectScope; } & (MacosLinuxConditionEntries | WindowsConditionEntries); +/** An Update to a Trusted App Entry */ +export type UpdateTrustedApp = NewTrustedApp & { + version?: string; +}; + /** A trusted app entry */ export type TrustedApp = NewTrustedApp & { + version: string; id: string; created_at: string; created_by: string; + updated_at: string; + updated_by: string; }; /** diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index c764c31a2d781..19de81cb95c3f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -13,6 +13,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; */ const allowedExperimentalValues = Object.freeze({ fleetServerEnabled: false, + trustedAppsByPolicyEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index 05c47605b4951..e27e9b5173fd5 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { IEsSearchResponse } from '../../../../../../src/plugins/data/common'; export type Maybe = T | null; @@ -70,10 +70,7 @@ export interface PaginationInputPaginated { querySize: number; } -export interface DocValueFields { - field: string; - format?: string | null; -} +export type DocValueFields = estypes.DocValueField; export interface Explanation { value: number; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 319933be5c79e..2160ed6170e29 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { estypes } from '@elastic/elasticsearch'; import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; import { @@ -64,13 +64,7 @@ import { MatrixHistogramRequestOptions, MatrixHistogramStrategyResponse, } from './matrix_histogram'; -import { - DocValueFields, - TimerangeInput, - SortField, - PaginationInput, - PaginationInputPaginated, -} from '../common'; +import { TimerangeInput, SortField, PaginationInput, PaginationInputPaginated } from '../common'; export * from './hosts'; export * from './matrix_histogram'; @@ -87,7 +81,7 @@ export interface RequestBasicOptions extends IEsSearchRequest { timerange: TimerangeInput; filterQuery: ESQuery | string | undefined; defaultIndex: string[]; - docValueFields?: DocValueFields[]; + docValueFields?: estypes.DocValueField[]; factoryQueryType?: FactoryQueryTypes; } diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 22e4179ae7050..79a0351b824e8 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -26,6 +26,19 @@ export const validate = ( return pipe(checked, fold(left, right)); }; +export const validateNonExact = ( + obj: object, + schema: T +): [t.TypeOf | null, string | null] => { + const decoded = schema.decode(obj); + const left = (errors: t.Errors): [T | null, string | null] => [ + null, + formatErrors(errors).join(','), + ]; + const right = (output: T): [T | null, string | null] => [output, null]; + return pipe(decoded, fold(left, right)); +}; + export const validateEither = ( schema: T, obj: A diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index ef9c7f49cb371..e1e78f8e310e1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -16,6 +16,7 @@ import { ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; +import { JSON_LINES } from '../../screens/alerts_details'; import { CUSTOM_RULES_BTN, RISK_SCORE, @@ -50,14 +51,17 @@ import { SCHEDULE_DETAILS, SEVERITY_DETAILS, TAGS_DETAILS, + TIMELINE_FIELD, TIMELINE_TEMPLATE_DETAILS, } from '../../screens/rule_details'; import { + expandFirstAlert, goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; +import { openJsonView, scrollJsonViewToBottom } from '../../tasks/alerts_details'; import { changeRowsPerPageTo300, duplicateFirstRule, @@ -98,7 +102,7 @@ import { import { waitForKibana } from '../../tasks/edit_rule'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; -import { goBackToAllRulesTable } from '../../tasks/rule_details'; +import { addsFieldsToTimeline, goBackToAllRulesTable } from '../../tasks/rule_details'; import { DETECTIONS_URL, RULE_CREATION } from '../../urls/navigation'; @@ -114,11 +118,11 @@ describe('indicator match', () => { before(() => { cleanKibana(); esArchiverLoad('threat_indicator'); - esArchiverLoad('threat_data'); + esArchiverLoad('suspicious_source_event'); }); after(() => { esArchiverUnload('threat_indicator'); - esArchiverUnload('threat_data'); + esArchiverUnload('suspicious_source_event'); }); describe('Creating new indicator match rules', () => { @@ -216,7 +220,7 @@ describe('indicator match', () => { it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getDefineContinueButton().click(); @@ -235,7 +239,7 @@ describe('indicator match', () => { it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'non-existent-value', validColumns: 'indexField', }); @@ -245,7 +249,7 @@ describe('indicator match', () => { it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorAndButton().click(); @@ -267,14 +271,14 @@ describe('indicator match', () => { it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'non-existent-value', validColumns: 'indexField', }); getIndicatorAndButton().click(); fillIndicatorMatchRow({ rowNumber: 2, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: 'second-non-existent-value', validColumns: 'indexField', }); @@ -305,7 +309,7 @@ describe('indicator match', () => { it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton().click(); @@ -317,7 +321,7 @@ describe('indicator match', () => { it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => { fillIndicatorMatchRow({ - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorAndButton().click(); @@ -330,16 +334,22 @@ describe('indicator match', () => { getIndicatorAndButton().click(); fillIndicatorMatchRow({ rowNumber: 3, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton(2).click(); - getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField(1).should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField(1).should( 'text', newThreatIndicatorRule.indicatorIndexField ); - getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField(2).should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField(2).should( 'text', newThreatIndicatorRule.indicatorIndexField @@ -357,11 +367,14 @@ describe('indicator match', () => { getIndicatorOrButton().click(); fillIndicatorMatchRow({ rowNumber: 2, - indexField: newThreatIndicatorRule.indicatorMapping, + indexField: newThreatIndicatorRule.indicatorMappingField, indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, }); getIndicatorDeleteButton().click(); - getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorIndexComboField().should( + 'text', + newThreatIndicatorRule.indicatorMappingField + ); getIndicatorMappingComboField().should( 'text', newThreatIndicatorRule.indicatorIndexField @@ -441,7 +454,7 @@ describe('indicator match', () => { ); getDetails(INDICATOR_MAPPING).should( 'have.text', - `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` + `${newThreatIndicatorRule.indicatorMappingField} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` ); getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); }); @@ -471,6 +484,74 @@ describe('indicator match', () => { }); }); + describe('Enrichment', () => { + const fieldSearch = 'threat.indicator.matched'; + const fields = [ + 'threat.indicator.matched.atomic', + 'threat.indicator.matched.type', + 'threat.indicator.matched.field', + ]; + const expectedFieldsText = [ + newThreatIndicatorRule.atomic, + newThreatIndicatorRule.type, + newThreatIndicatorRule.indicatorMappingField, + ]; + + const expectedEnrichment = [ + { line: 4, text: ' "threat": {' }, + { + line: 3, + text: + ' "indicator": "{\\"first_seen\\":\\"2021-03-10T08:02:14.000Z\\",\\"file\\":{\\"size\\":80280,\\"pe\\":{},\\"type\\":\\"elf\\",\\"hash\\":{\\"sha256\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"tlsh\\":\\"6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE\\",\\"ssdeep\\":\\"1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL\\",\\"md5\\":\\"9b6c3518a91d23ed77504b5416bfb5b3\\"}},\\"type\\":\\"file\\",\\"matched\\":{\\"atomic\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"field\\":\\"myhash.mysha256\\",\\"id\\":\\"84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f\\",\\"index\\":\\"filebeat-7.12.0-2021.03.10-000001\\",\\"type\\":\\"file\\"}}"', + }, + { line: 2, text: ' }' }, + ]; + + before(() => { + cleanKibana(); + esArchiverLoad('threat_indicator'); + esArchiverLoad('suspicious_source_event'); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + createCustomIndicatorRule(newThreatIndicatorRule); + reload(); + }); + + after(() => { + esArchiverUnload('threat_indicator'); + esArchiverUnload('suspicious_source_event'); + }); + + beforeEach(() => { + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + }); + + it('Displays matches on the timeline', () => { + addsFieldsToTimeline(fieldSearch, fields); + + fields.forEach((field, index) => { + cy.get(TIMELINE_FIELD(field)).should('have.text', expectedFieldsText[index]); + }); + }); + + it('Displays enrichment on the JSON view', () => { + expandFirstAlert(); + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + expectedEnrichment.forEach((enrichment) => { + cy.wrap(elements) + .eq(length - enrichment.line) + .should('have.text', enrichment.text); + }); + }); + }); + }); + describe('Duplicates the indicator rule', () => { beforeEach(() => { cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts index 2e0599dfcae21..dee921b0c668a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts @@ -133,7 +133,7 @@ describe('Exceptions modal', () => { closeExceptionBuilderModal(); }); - it.skip('Does not overwrite values of nested entry items', () => { + it('Does not overwrite values of nested entry items', () => { openExceptionModalFromRuleSettings(); cy.get(LOADING_SPINNER).should('not.exist'); @@ -144,13 +144,14 @@ describe('Exceptions modal', () => { // exception item 2 with nested field cy.get(ADD_OR_BTN).click(); - addExceptionEntryFieldValueOfItemX('c', 1, 0); + addExceptionEntryFieldValueOfItemX('agent.name', 1, 0); cy.get(ADD_NESTED_BTN).click(); addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); cy.get(ADD_AND_BTN).click(); addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); // This button will now read `Add non-nested button` - cy.get(ADD_NESTED_BTN).click(); + cy.get(ADD_NESTED_BTN).scrollIntoView(); + cy.get(ADD_NESTED_BTN).focus().click(); addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); // should have only deleted `user.id` @@ -161,7 +162,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); cy.get(EXCEPTION_ITEM_CONTAINER) @@ -178,7 +183,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) .find(FIELD_INPUT) diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 88dcd998fc06d..68c7796f7ca3b 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -71,8 +71,10 @@ export interface OverrideRule extends CustomRule { export interface ThreatIndicatorRule extends CustomRule { indicatorIndexPattern: string[]; - indicatorMapping: string; + indicatorMappingField: string; indicatorIndexField: string; + type?: string; + atomic?: string; } export interface MachineLearningRule { @@ -299,7 +301,7 @@ export const eqlSequenceRule: CustomRule = { export const newThreatIndicatorRule: ThreatIndicatorRule = { name: 'Threat Indicator Rule Test', description: 'The threat indicator rule description.', - index: ['threat-data-*'], + index: ['suspicious-*'], severity: 'Critical', riskScore: '20', tags: ['test', 'threat'], @@ -309,9 +311,11 @@ export const newThreatIndicatorRule: ThreatIndicatorRule = { note: '# test markdown', runsEvery, lookBack, - indicatorIndexPattern: ['threat-indicator-*'], - indicatorMapping: 'agent.id', - indicatorIndexField: 'agent.threat', + indicatorIndexPattern: ['filebeat-*'], + indicatorMappingField: 'myhash.mysha256', + indicatorIndexField: 'threatintel.indicator.file.hash.sha256', + type: 'file', + atomic: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', timeline, maxSignals: 100, }; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts new file mode 100644 index 0000000000000..417cf73de47f6 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const JSON_CONTENT = '[data-test-subj="jsonView"]'; + +export const JSON_LINES = '.ace_line'; + +export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts index ea274c446c014..1115dfb00914e 100644 --- a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts @@ -5,10 +5,12 @@ * 2.0. */ +export const CLOSE_BTN = '[data-test-subj="close"]'; + export const FIELDS_BROWSER_CATEGORIES_COUNT = '[data-test-subj="categories-count"]'; export const FIELDS_BROWSER_CHECKBOX = (id: string) => { - return `[data-test-subj="field-${id}-checkbox`; + return `[data-test-subj="category-table-container"] [data-test-subj="field-${id}-checkbox"]`; }; export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index f9590b34a0a11..d94be17a0530a 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -53,6 +53,9 @@ export const MACHINE_LEARNING_JOB_STATUS = '[data-test-subj="machineLearningJobS export const MITRE_ATTACK_DETAILS = 'MITRE ATT&CK'; +export const FIELDS_BROWSER_BTN = + '[data-test-subj="events-viewer-panel"] [data-test-subj="show-field-browser"]'; + export const REFRESH_BUTTON = '[data-test-subj="refreshButton"]'; export const RULE_ABOUT_DETAILS_HEADER_TOGGLE = '[data-test-subj="stepAboutDetailsToggle"]'; @@ -92,6 +95,10 @@ export const TIMELINE_TEMPLATE_DETAILS = 'Timeline template'; export const TIMESTAMP_OVERRIDE_DETAILS = 'Timestamp override'; +export const TIMELINE_FIELD = (field: string) => { + return `[data-test-subj="draggable-content-${field}"]`; +}; + export const getDetails = (title: string) => cy.get(DETAILS_TITLE).contains(title).next(DETAILS_DESCRIPTION); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts new file mode 100644 index 0000000000000..1582f35989e2c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.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 { JSON_CONTENT, JSON_VIEW_TAB } from '../screens/alerts_details'; + +export const openJsonView = () => { + cy.get(JSON_VIEW_TAB).click(); +}; + +export const scrollJsonViewToBottom = () => { + cy.get(JSON_CONTENT).click({ force: true }); + cy.get(JSON_CONTENT).type('{pagedown}{pagedown}{pagedown}'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 4bf5508c19aa9..0b051f3a26581 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -45,9 +45,9 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r { entries: [ { - field: rule.indicatorMapping, + field: rule.indicatorMappingField, type: 'mapping', - value: rule.indicatorMapping, + value: rule.indicatorIndexField, }, ], }, @@ -55,13 +55,13 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r threat_query: '*:*', threat_language: 'kuery', threat_filters: [], - threat_index: ['mock*'], + threat_index: rule.indicatorIndexPattern, threat_indicator_path: '', from: 'now-17520h', - index: ['exceptions-*'], + index: rule.index, query: rule.customQuery || '*:*', language: 'kuery', - enabled: false, + enabled: true, }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index b317f158ae614..0c663a95a4bda 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -426,7 +426,7 @@ export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQ export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => { fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern); fillIndicatorMatchRow({ - indexField: rule.indicatorMapping, + indexField: rule.indicatorMappingField, indicatorIndexField: rule.indicatorIndexField, }); getDefineContinueButton().should('exist').click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index 9ee242dcebbe8..72945f557ac1b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -15,8 +15,15 @@ import { FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX, FIELDS_BROWSER_MESSAGE_CHECKBOX, FIELDS_BROWSER_RESET_FIELDS, + FIELDS_BROWSER_CHECKBOX, + CLOSE_BTN, } from '../screens/fields_browser'; -import { KQL_SEARCH_BAR } from '../screens/hosts/main'; + +export const addsFields = (fields: string[]) => { + fields.forEach((field) => { + cy.get(FIELDS_BROWSER_CHECKBOX(field)).click(); + }); +}; export const addsHostGeoCityNameToTimeline = () => { cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check({ @@ -44,7 +51,7 @@ export const clearFieldsBrowser = () => { }; export const closeFieldsBrowser = () => { - cy.get(KQL_SEARCH_BAR).click({ force: true }); + cy.get(CLOSE_BTN).click({ force: true }); }; export const filterFieldsBrowser = (fieldName: string) => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 21a2745395419..37c425c5488bc 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -20,10 +20,12 @@ import { ALERTS_TAB, BACK_TO_RULES, EXCEPTIONS_TAB, + FIELDS_BROWSER_BTN, REFRESH_BUTTON, REMOVE_EXCEPTION_BTN, RULE_SWITCH, } from '../screens/rule_details'; +import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const activatesRule = () => { cy.intercept('PATCH', '/api/detection_engine/rules/_bulk_update').as('bulk_update'); @@ -49,6 +51,13 @@ export const addsException = (exception: Exception) => { cy.get(CONFIRM_BTN).should('not.exist'); }; +export const addsFieldsToTimeline = (search: string, fields: string[]) => { + cy.get(FIELDS_BROWSER_BTN).click(); + filterFieldsBrowser(search); + addsFields(fields); + closeFieldsBrowser(); +}; + export const openExceptionModalFromRuleSettings = () => { cy.get(ADD_EXCEPTIONS_BTN).click(); cy.get(LOADING_SPINNER).should('not.exist'); diff --git a/x-pack/plugins/security_solution/jest.config.js b/x-pack/plugins/security_solution/jest.config.js index 700eaebf6c202..b4dcedfcceeee 100644 --- a/x-pack/plugins/security_solution/jest.config.js +++ b/x-pack/plugins/security_solution/jest.config.js @@ -9,4 +9,7 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/security_solution'], + + // TODO: migrate to "jest-environment-jsdom" https://github.com/elastic/kibana/issues/95201 + testEnvironment: 'jest-environment-jsdom-thirteen', }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index ddb3d98cafca8..4979d70ce2d7b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -107,6 +107,7 @@ const EventDetailsComponent: React.FC = ({ }, { id: EventsViewType.jsonView, + 'data-test-subj': 'jsonViewTab', name: i18n.JSON_VIEW, content: ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index b0ffcb8c5b5b8..7e9e7c40258da 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -115,7 +115,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ onRuleChange, alertStatus, }: AddExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [errorsExist, setErrorExists] = useState(false); const [comment, setComment] = useState(''); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -394,6 +394,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} ({ v4: jest.fn().mockReturnValue('123'), })); -const getEntryNestedWithIdMock = () => ({ - id: '123', - ...getEntryNestedMock(), -}); - -const getEntryExistsWithIdMock = () => ({ - id: '123', - ...getEntryExistsMock(), -}); - -const getEntryMatchWithIdMock = () => ({ - id: '123', - ...getEntryMatchMock(), -}); - -const getEntryMatchAnyWithIdMock = () => ({ - id: '123', - ...getEntryMatchAnyMock(), -}); - const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', title: 'logstash-*', fields, }); -const getMockBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('ip'), - operator: isOperator, - value: 'some value', - nested: undefined, - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('nestedField.child'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, - operator: isOperator, - value: undefined, - nested: 'parent', - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - const mockEndpointFields = [ { name: 'file.path.caseless', @@ -154,1254 +48,22 @@ export const getEndpointField = (name: string) => mockEndpointFields.find((field) => field.name === name) as IFieldType; describe('Exception builder helpers', () => { - describe('#getCorrespondingKeywordField', () => { - test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw.text', - }); + describe('#filterIndexPatterns', () => { + test('it returns index patterns without filtering if list type is "detection"', () => { + const mockIndexPatterns = getMockIndexPattern(); + const output = filterIndexPatterns(mockIndexPatterns, 'detection'); - expect(output).toEqual(getField('machine.os.raw')); + expect(output).toEqual(mockIndexPatterns); }); - test('it returns undefined if "selectedFieldIsTextType" is false', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is empty string', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: '', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is undefined', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: undefined, - }); - - expect(output).toEqual(undefined); - }); - }); - - describe('#getFilteredIndexPatterns', () => { - describe('list type detections', () => { - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'child' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [...fields], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - - describe('list type endpoint', () => { - let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - - beforeAll(() => { - payloadIndexPattern = { - ...payloadIndexPattern, - fields: [...payloadIndexPattern.fields, ...mockEndpointFields], - }; - }); - - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - id: '123', - field: getEndpointField('file.Ext.code_signature.status'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: 'status' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: { - ...getEndpointField('file.Ext.code_signature.status'), - name: 'file.Ext.code_signature', - esTypes: ['nested'], - }, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['nested'], - name: 'file.Ext.code_signature', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'file.Ext.code_signature', - }, - }, - type: 'string', - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [getEndpointField('file.Ext.code_signature.status')], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['keyword'], - name: 'file.path.caseless', - readFromDocValues: false, - scripted: false, - searchable: true, - type: 'string', - }, - { - name: 'file.Ext.code_signature.status', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: true, - aggregatable: false, - readFromDocValues: false, - subType: { nested: { path: 'file.Ext.code_signature' } }, - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - }); - - describe('#getFormattedBuilderEntry', () => { - test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { - const payloadIndexPattern: IIndexPattern = { + test('it returns filtered index patterns if list type is "endpoint"', () => { + const mockIndexPatterns = { ...getMockIndexPattern(), - fields: [ - ...fields, - { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - ], - }; - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'machine.os.raw.text', - value: 'some os', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some os', - correspondingKeywordField: getField('machine.os.raw'), - }; - expect(output).toEqual(expected); - }); - - test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - payloadParent, - 1 - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [{ ...payloadItem }], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - - test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'ip', - value: 'some ip', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#isEntryNested', () => { - test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchWithIdMock(); - const output = isEntryNested(payload); - const expected = false; - expect(output).toEqual(expected); - }); - - test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedWithIdMock(); - const output = isEntryNested(payload); - const expected = true; - expect(output).toEqual(expected); - }); - }); - - describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: undefined, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when no nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, - ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: true, - count: 0, - esTypes: ['keyword'], - name: 'extension', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'string', - }, - nested: undefined, - operator: isOneOfOperator, - parent: undefined, - value: ['some extension'], - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...payloadParent }, - ]; - - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: false, - esTypes: ['nested'], - name: 'nestedField', - searchable: false, - type: 'string', - }, - nested: 'parent', - operator: isOperator, - parent: undefined, - value: undefined, - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some host name', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - }); - - describe('#getUpdatedEntriesOnDelete', () => { - test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: ENTRIES_WITH_IDS, - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - id: '123', - field: 'some.not.nested.field', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes nested entry of "entryIndex" with corresponding parent index', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [], - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryFromOperator', () => { - test('it returns current value when switching from "is" to "is not"', () => { - const payloadOperator: OperatorOption = isNotOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not" to "is"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is one of" to "is not one of"', () => { - const payloadOperator: OperatorOption = isNotOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not one of" to "is one of"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match_any"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], + fields: [...fields, ...mockEndpointFields], }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "exists" to "does not exist"', () => { - const payloadOperator: OperatorOption = doesNotExistOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: existsOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "does not exist" to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: doesNotExistOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "list"', () => { - const payloadOperator: OperatorOption = isInListOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryList & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'list', - list: { id: '', type: 'ip' }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getOperatorOptions', () => { - test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" if no field selected', () => { - const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); + const output = filterIndexPatterns(mockIndexPatterns, 'endpoint'); - test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', true); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [isOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns all operator options if "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = EXCEPTION_OPERATORS; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [ - isOperator, - isNotOperator, - existsOperator, - doesNotExistOperator, - ]; - expect(output).toEqual(expected); - }); - - test('it returns list operators if specified to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, true); - expect(output).toEqual(EXCEPTION_OPERATORS); - }); - - test('it does not return list operators if specified not to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, false); - expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); - }); - }); - - describe('#getEntryOnFieldChange', () => { - test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [ - { ...getEntryMatchWithIdMock(), field: 'child' }, - getEntryMatchAnyWithIdMock(), - ], - }, - parentIndex: 0, - }, - }; - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - getEntryMatchAnyWithIdMock(), - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns field of type "match" with updated field if not a nested entry', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadIFieldType: IFieldType = getField('ip'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnOperatorChange', () => { - test('it returns updated subentry preserving its value when entry is not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'some value', - operator: 'excluded', - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: [], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.EXCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchAnyChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - field: undefined, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - field: undefined, - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnListChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - field: undefined, - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); + expect(output).toEqual({ ...getMockIndexPattern(), fields: [...mockEndpointFields] }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 8afdbce68c69a..0ad9814484a2f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -7,616 +7,21 @@ import uuid from 'uuid'; -import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; -import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; -import { - Entry, - OperatorTypeEnum, - EntryNested, - ExceptionListType, - entriesList, - ListSchema, - OperatorEnum, -} from '../../../../lists_plugin_deps'; -import { - isOperator, - existsOperator, - isOneOfOperator, - EXCEPTION_OPERATORS, - EXCEPTION_OPERATORS_SANS_LISTS, - isNotOperator, - doesNotExistOperator, -} from '../../autocomplete/operators'; -import { OperatorOption } from '../../autocomplete/types'; -import { - BuilderEntry, - FormattedBuilderEntry, - ExceptionsBuilderExceptionItem, - EmptyEntry, - EmptyNestedEntry, -} from '../types'; -import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { OperatorTypeEnum, ExceptionListType, OperatorEnum } from '../../../../lists_plugin_deps'; +import { ExceptionsBuilderExceptionItem, EmptyEntry, EmptyNestedEntry } from '../types'; import exceptionableFields from '../exceptionable_fields.json'; -/** - * Returns filtered index patterns based on the field - if a user selects to - * add nested entry, should only show nested fields, if item is the parent - * field of a nested entry, we only display the parent field - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * set to add a nested field - */ -export const getFilteredIndexPatterns = ( +export const filterIndexPatterns = ( patterns: IIndexPattern, - item: FormattedBuilderEntry, type: ExceptionListType ): IIndexPattern => { - const indexPatterns = { - ...patterns, - fields: patterns.fields.filter(({ name }) => - type === 'endpoint' ? exceptionableFields.includes(name) : true - ), - }; - - if (item.nested === 'child' && item.parent != null) { - // when user has selected a nested entry, only fields with the common parent are shown - return { - ...indexPatterns, - fields: indexPatterns.fields - .filter((indexField) => { - const fieldHasCommonParentPath = - indexField.subType != null && - indexField.subType.nested != null && - item.parent != null && - indexField.subType.nested.path === item.parent.parent.field; - - return fieldHasCommonParentPath; - }) - .map((f) => { - const fieldNameWithoutParentPath = f.name.split('.').slice(-1)[0]; - return { ...f, name: fieldNameWithoutParentPath }; - }), - }; - } else if (item.nested === 'parent' && item.field != null) { - // when user has selected a nested entry, right above it we show the common parent - return { ...indexPatterns, fields: [item.field] }; - } else if (item.nested === 'parent' && item.field == null) { - // when user selects to add a nested entry, only nested fields are shown as options - return { - ...indexPatterns, - fields: indexPatterns.fields.filter( - (field) => field.subType != null && field.subType.nested != null - ), - }; - } else { - return indexPatterns; - } -}; - -/** - * Fields of type 'text' do not generate autocomplete values, we want - * to find it's corresponding keyword type (if available) which does - * generate autocomplete values - * - * @param fields IFieldType fields - * @param selectedField the field name that was selected - * @param isTextType we only want a corresponding keyword field if - * the selected field is of type 'text' - * - */ -export const getCorrespondingKeywordField = ({ - fields, - selectedField, -}: { - fields: IFieldType[]; - selectedField: string | undefined; -}): IFieldType | undefined => { - const selectedFieldBits = - selectedField != null && selectedField !== '' ? selectedField.split('.') : []; - const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; - - if (selectedFieldIsTextType && selectedFieldBits.length > 0) { - const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); - const [foundKeywordField] = fields.filter( - ({ name }) => keywordField !== '' && keywordField === name - ); - return foundKeywordField; - } - - return undefined; -}; - -/** - * Formats the entry into one that is easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * @param itemIndex entry index - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntry = ( - indexPattern: IIndexPattern, - item: BuilderEntry, - itemIndex: number, - parent: EntryNested | undefined, - parentIndex: number | undefined -): FormattedBuilderEntry => { - const { fields } = indexPattern; - const field = parent != null ? `${parent.field}.${item.field}` : item.field; - const [foundField] = fields.filter(({ name }) => field != null && field === name); - const correspondingKeywordField = getCorrespondingKeywordField({ - fields, - selectedField: field, - }); - - if (parent != null && parentIndex != null) { - return { - field: - foundField != null - ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } - : foundField, - correspondingKeywordField, - id: item.id ?? `${itemIndex}`, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: 'child', - parent: { parent, parentIndex }, - entryIndex: itemIndex, - }; - } else { - return { - field: foundField, - id: item.id ?? `${itemIndex}`, - correspondingKeywordField, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: undefined, - parent: undefined, - entryIndex: itemIndex, - }; - } -}; - -export const isEntryNested = (item: BuilderEntry): item is EntryNested => { - return (item as EntryNested).entries != null; -}; - -/** - * Formats the entries to be easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param entries exception item entries - * @param addNested boolean noting whether or not UI is currently - * set to add a nested field - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntries = ( - indexPattern: IIndexPattern, - entries: BuilderEntry[], - parent?: EntryNested, - parentIndex?: number -): FormattedBuilderEntry[] => { - return entries.reduce((acc, item, index) => { - const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; - if (item.type !== 'nested' && !isNewNestedEntry) { - const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( - indexPattern, - item, - index, - parent, - parentIndex - ); - return [...acc, newItemEntry]; - } else { - const parentEntry: FormattedBuilderEntry = { - operator: isOperator, - id: item.id ?? `${index}`, - nested: 'parent', - field: isNewNestedEntry - ? undefined - : { - name: item.field ?? '', - aggregatable: false, - searchable: false, - type: 'string', - esTypes: ['nested'], - }, - value: undefined, - entryIndex: index, - parent: undefined, - correspondingKeywordField: undefined, - }; - - // User has selected to add a nested field, but not yet selected the field - if (isNewNestedEntry) { - return [...acc, parentEntry]; + return type === 'endpoint' + ? { + ...patterns, + fields: patterns.fields.filter(({ name }) => exceptionableFields.includes(name)), } - - if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); - - return [...acc, parentEntry, ...nestedItems]; - } - - return [...acc]; - } - }, []); -}; - -/** - * Determines whether an entire entry, exception item, or entry within a nested - * entry needs to be removed - * - * @param exceptionItem - * @param entryIndex index of given entry, for nested entries, this will correspond - * to their parent index - * @param nestedEntryIndex index of nested entry - * - */ -export const getUpdatedEntriesOnDelete = ( - exceptionItem: ExceptionsBuilderExceptionItem, - entryIndex: number, - nestedParentIndex: number | null -): ExceptionsBuilderExceptionItem => { - const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; - - if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries = [ - ...itemOfInterest.entries.slice(0, entryIndex), - ...itemOfInterest.entries.slice(entryIndex + 1), - ]; - - if (updatedEntryEntries.length === 0) { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } else { - const { field } = itemOfInterest; - const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { - field, - id: itemOfInterest.id ?? `${entryIndex}`, - type: OperatorTypeEnum.NESTED, - entries: updatedEntryEntries, - }; - - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - updatedItemOfInterest, - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } - } else { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ], - }; - } -}; - -/** - * On operator change, determines whether value needs to be cleared or not - * - * @param field - * @param selectedOperator - * @param currentEntry - * - */ -export const getEntryFromOperator = ( - selectedOperator: OperatorOption, - currentEntry: FormattedBuilderEntry -): Entry & { id?: string } => { - const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; - const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; - switch (selectedOperator.type) { - case 'match': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH, - operator: selectedOperator.operator, - value: - isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', - }; - case 'match_any': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH_ANY, - operator: selectedOperator.operator, - value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], - }; - case 'list': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.LIST, - operator: selectedOperator.operator, - list: { id: '', type: 'ip' }, - }; - default: - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.EXISTS, - operator: selectedOperator.operator, - }; - } -}; - -/** - * Determines which operators to make available - * - * @param item - * @param listType - * @param isBoolean - * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators - */ -export const getOperatorOptions = ( - item: FormattedBuilderEntry, - listType: ExceptionListType, - isBoolean: boolean, - includeValueListOperators = true -): OperatorOption[] => { - if (item.nested === 'parent' || item.field == null) { - return [isOperator]; - } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { - return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; - } else if (item.nested != null && listType === 'detection') { - return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; - } else { - return isBoolean - ? [isOperator, isNotOperator, existsOperator, doesNotExistOperator] - : includeValueListOperators - ? EXCEPTION_OPERATORS - : EXCEPTION_OPERATORS_SANS_LISTS; - } -}; - -/** - * Determines proper entry update when user selects new field - * - * @param item - current exception item entry values - * @param newField - newly selected field - * - */ -export const getEntryOnFieldChange = ( - item: FormattedBuilderEntry, - newField: IFieldType -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, nested } = item; - const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; - - if (nested === 'parent') { - // For nested entries, when user first selects to add a nested - // entry, they first see a row similar to what is shown for when - // a user selects "exists", as soon as they make a selection - // we can now identify the 'parent' and 'child' this is where - // we first convert the entry into type "nested" - const newParentFieldValue = - newField.subType != null && newField.subType.nested != null - ? newField.subType.nested.path - : ''; - - return { - updatedEntry: { - id: item.id, - field: newParentFieldValue, - type: OperatorTypeEnum.NESTED, - entries: [ - addIdToItem({ - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }), - ], - }, - index: entryIndex, - }; - } else if (nested === 'child' && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: newField != null ? newField.name : '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user selects new operator - * - * @param item - current exception item entry values - * @param newOperator - newly selected operator - * - */ -export const getEntryOnOperatorChange = ( - item: FormattedBuilderEntry, - newOperator: OperatorOption -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, field, nested } = item; - const newEntry = getEntryFromOperator(newOperator, item); - - if (!entriesList.is(newEntry) && nested != null && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - ...newEntry, - field: field != null ? field.name.split('.').slice(-1)[0] : '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { updatedEntry: newEntry, index: entryIndex }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchChange = ( - item: FormattedBuilderEntry, - newField: string -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match_any" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchAnyChange = ( - item: FormattedBuilderEntry, - newField: string[] -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "list" - * - * @param item - current exception item entry values - * @param newField - newly selected list - * - */ -export const getEntryOnListChange = ( - item: FormattedBuilderEntry, - newField: ListSchema -): { updatedEntry: BuilderEntry; index: number } => { - const { entryIndex, field, operator } = item; - const { id, type } = newField; - - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.LIST, - operator: operator.operator, - list: { id, type }, - }, - index: entryIndex, - }; + : patterns; }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx index 4d0e3306e3315..2863b92ca68ab 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx @@ -17,37 +17,26 @@ import { import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; -import { useKibana } from '../../../../common/lib/kibana'; import { getEmptyValue } from '../../empty_value'; import { ExceptionBuilderComponent } from './'; import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; +import { coreMock } from 'src/core/public/mocks'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece', }, }); - -jest.mock('../../../../common/lib/kibana'); +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('ExceptionBuilderComponent', () => { let wrapper: ReactWrapper; const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - afterEach(() => { getValueSuggestionsMock.mockClear(); jest.clearAllMocks(); @@ -58,6 +47,8 @@ describe('ExceptionBuilderComponent', () => { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 954a75fc370bd..e33478ad99660 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -98,7 +98,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ onConfirm, onRuleChange, }: EditExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [comment, setComment] = useState(''); const [errorsExist, setErrorExists] = useState(false); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -313,6 +313,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} > = memo( ItemDetailsAction.displayName = 'ItemDetailsAction'; -export const ItemDetailsCard: FC = memo(({ children }) => { - const childElements = useMemo( - () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), - [children] - ); - - return ( - - - - - {childElements.get(ItemDetailsPropertySummary)} - - - - - -
{childElements.get(OTHER_NODES)}
-
- {childElements.has(ItemDetailsAction) && ( - - - {childElements.get(ItemDetailsAction)?.map((action, index) => ( - - {action} - - ))} - +export type ItemDetailsCardProps = PropsWithChildren<{ + 'data-test-subj'?: string; +}>; +export const ItemDetailsCard = memo( + ({ children, 'data-test-subj': dataTestSubj }) => { + const childElements = useMemo( + () => groupChildrenByType(children, [ItemDetailsPropertySummary, ItemDetailsAction]), + [children] + ); + + return ( + + + + + {childElements.get(ItemDetailsPropertySummary)} + + + + + +
{childElements.get(OTHER_NODES)}
- )} -
-
-
-
- ); -}); + {childElements.has(ItemDetailsAction) && ( + + + {childElements.get(ItemDetailsAction)?.map((action, index) => ( + + {action} + + ))} + + + )} +
+
+
+
+ ); + } +); ItemDetailsCard.displayName = 'ItemDetailsCard'; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 6daced08be282..f66b060b166bf 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -94,7 +94,7 @@ export const getDocValueFields = memoizeOne( ...accumulator, { field: field.name, - format: field.format, + format: field.format ? field.format : undefined, }, ]; } diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.test.ts new file mode 100644 index 0000000000000..2ac5948641d7d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { useIsExperimentalFeatureEnabled } from './use_experimental_features'; + +jest.mock('react-redux'); +const useSelectorMock = useSelector as jest.Mock; +const mockAppState = { + app: { + enableExperimental: { + featureA: true, + featureB: false, + }, + }, +}; + +describe('useExperimentalFeatures', () => { + beforeEach(() => { + useSelectorMock.mockImplementation((cb) => { + return cb(mockAppState); + }); + }); + afterEach(() => { + useSelectorMock.mockClear(); + }); + it('throws an error when unexisting feature', async () => { + expect(() => + useIsExperimentalFeatureEnabled('unexistingFeature' as keyof ExperimentalFeatures) + ).toThrowError(); + }); + it('returns true when existing feature and is enabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureA' as keyof ExperimentalFeatures); + + expect(result).toBeTruthy(); + }); + it('returns false when existing feature and is disabled', async () => { + const result = useIsExperimentalFeatureEnabled('featureB' as keyof ExperimentalFeatures); + + expect(result).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts new file mode 100644 index 0000000000000..247b7624914cf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { State } from '../../common/store'; +import { + ExperimentalFeatures, + getExperimentalAllowedValues, +} from '../../../common/experimental_features'; + +const allowedExperimentalValues = getExperimentalAllowedValues(); + +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { + return useSelector(({ app: { enableExperimental } }: State) => { + if (!enableExperimental || !(feature in enableExperimental)) { + throw new Error( + `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( + ', ' + )}` + ); + } + return enableExperimental[feature]; + }); +}; diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 38ecedc0c7ba7..5a252e4aa48f2 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { Note } from '../../lib/note'; export type ErrorState = ErrorModel; @@ -24,4 +25,5 @@ export type ErrorModel = Error[]; export interface AppModel { notesById: NotesById; errors: ErrorState; + enableExperimental?: ExperimentalFeatures; } diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 9a2289765e85d..d2808a02c8621 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { parseExperimentalConfigValue } from '../../..//common/experimental_features'; import { createInitialState } from './reducer'; jest.mock('../lib/kibana', () => ({ @@ -22,6 +23,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: ['auditbeat-*', 'filebeat'], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); @@ -35,6 +37,7 @@ describe('createInitialState', () => { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: [], signalIndexName: 'siem-signals-default', + enableExperimental: parseExperimentalConfigValue([]), } ); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 27fddafc3781f..c2ef2563fe63e 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -21,6 +21,7 @@ import { ManagementPluginReducer } from '../../management'; import { State } from './types'; import { AppAction } from './actions'; import { KibanaIndexPatterns } from './sourcerer/model'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; export type SubPluginsInitReducer = HostsPluginReducer & NetworkPluginReducer & @@ -36,14 +37,16 @@ export const createInitialState = ( kibanaIndexPatterns, configIndexPatterns, signalIndexName, + enableExperimental, }: { kibanaIndexPatterns: KibanaIndexPatterns; configIndexPatterns: string[]; signalIndexName: string | null; + enableExperimental: ExperimentalFeatures; } ): PreloadedState => { const preloadedState: PreloadedState = { - app: initialAppState, + app: { ...initialAppState, enableExperimental }, dragAndDrop: initialDragAndDropState, ...pluginsInitState, inputs: createInitialInputsState(), 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 c1d54192c86b1..7616dfccddaff 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 @@ -9,6 +9,10 @@ import { Dispatch } from 'redux'; import { State, ImmutableMiddlewareFactory } from './types'; import { AppAction } from './actions'; +interface WaitForActionOptions { + validate?: (action: A extends { type: T } ? A : never) => boolean; +} + /** * Utilities for testing Redux middleware */ @@ -21,7 +25,10 @@ export interface MiddlewareActionSpyHelper(actionType: T) => Promise; + waitForAction: ( + actionType: T, + options?: WaitForActionOptions + ) => Promise; /** * A property holding the information around the calls that were processed by the internal * `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked @@ -78,7 +85,7 @@ export const createSpyMiddleware = < let spyDispatch: jest.Mock>; return { - waitForAction: async (actionType) => { + waitForAction: async (actionType, options = {}) => { 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 @@ -87,6 +94,10 @@ export const createSpyMiddleware = < return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { if (action.type === actionType) { + if (options.validate && !options.validate(action as ResolvedAction)) { + return; + } + watchers.delete(watch); clearTimeout(timeout); resolve(action as ResolvedAction); diff --git a/x-pack/plugins/security_solution/public/common/types.ts b/x-pack/plugins/security_solution/public/common/types.ts index 68346847eb8d1..f1a7cdc8abc60 100644 --- a/x-pack/plugins/security_solution/public/common/types.ts +++ b/x-pack/plugins/security_solution/public/common/types.ts @@ -10,3 +10,7 @@ export interface ServerApiError { error: string; message: string; } + +export interface SecuritySolutionUiConfigType { + enableExperimental: string[]; +} diff --git a/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts b/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts index 3962d6c946a48..e63a2b20a5ad5 100644 --- a/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts +++ b/x-pack/plugins/security_solution/public/common/utils/use_mount_appended.ts @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { mount } from 'enzyme'; type WrapperOf any> = (...args: Parameters) => ReturnType; // eslint-disable-line diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx index 5dbe1f1cef5be..fb71c6c4b0350 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx @@ -94,7 +94,26 @@ describe('RuleActionsField', () => { `); }); - it('if we do NOT have an error on case action creation, we are supporting case connector', () => { + // sub-cases-enabled: remove this once the sub cases and connector feature is completed + // https://github.com/elastic/kibana/issues/94115 + it('should not contain the case connector as a supported action', () => { + expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "enabledInConfig": false, + "enabledInLicense": true, + "id": ".jira", + "minimumLicenseRequired": "gold", + "name": "My Jira", + }, + ] + `); + }); + + // sub-cases-enabled: unskip after sub cases and the case connector is supported + // https://github.com/elastic/kibana/issues/94115 + it.skip('if we do NOT have an error on case action creation, we are supporting case connector', () => { expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(` Array [ Object { 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 cbcc054e7c6a9..bf754720f314b 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -108,6 +108,7 @@ const normalizeTrustedAppsPageLocation = ( : {}), ...(!isDefaultOrMissing(location.view_type, 'grid') ? { view_type: location.view_type } : {}), ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), }; } else { return {}; @@ -147,11 +148,20 @@ export const extractListPaginationParams = (query: querystring.ParsedUrlQuery) = export const extractTrustedAppsListPageLocation = ( query: querystring.ParsedUrlQuery -): TrustedAppsListPageLocation => ({ - ...extractListPaginationParams(query), - view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', - show: extractFirstParamValue(query, 'show') === 'create' ? 'create' : undefined, -}); +): TrustedAppsListPageLocation => { + const showParamValue = extractFirstParamValue( + query, + 'show' + ) as TrustedAppsListPageLocation['show']; + + return { + ...extractListPaginationParams(query), + view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', + show: + showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, + id: extractFirstParamValue(query, 'id'), + }; +}; export const getTrustedAppsListPath = (location?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index 404ee0cd4aa2c..40b843a676d9c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -54,7 +54,7 @@ export const mockEndpointResultList: (options?: { for (let index = 0; index < actualCountToReturn; index++) { hosts.push({ metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: queryStrategyVersion, }); } @@ -74,7 +74,7 @@ export const mockEndpointResultList: (options?: { export const mockEndpointDetailsApiResult = (): HostInfo => { return { metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }; 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 17ce24e7cda7f..eec4de6400145 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 @@ -231,7 +231,7 @@ export const showView: (state: EndpointState) => 'policy_response' | 'details' = export const hostStatusInfo: (state: Immutable) => HostStatus = createSelector( (state) => state.hostStatus, (hostStatus) => { - return hostStatus ? hostStatus : HostStatus.ERROR; + return hostStatus ? hostStatus : HostStatus.UNHEALTHY; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index eb3e534ba427f..c97e097ea9b72 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -8,7 +8,6 @@ import styled from 'styled-components'; import { EuiDescriptionList, - EuiHealth, EuiHorizontalRule, EuiListGroup, EuiListGroupItem, @@ -17,6 +16,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, + EuiSpacer, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,11 +26,7 @@ import { HostInfo, HostMetadata, HostStatus } from '../../../../../../common/end import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; -import { - POLICY_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_BADGE_COLOR, - HOST_STATUS_TO_HEALTH_COLOR, -} from '../host_constants'; +import { POLICY_STATUS_TO_BADGE_COLOR, HOST_STATUS_TO_BADGE_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; @@ -48,17 +44,6 @@ const HostIds = styled(EuiListGroupItem)` } `; -const LinkToExternalApp = styled.div` - margin-top: ${(props) => props.theme.eui.ruleMargins.marginMedium}; - .linkToAppIcon { - margin-right: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - vertical-align: top; - } - .linkToAppPopoutIcon { - margin-left: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - } -`; - const openReassignFlyoutSearch = '?openReassignFlyout=true'; export const EndpointDetails = memo( @@ -80,7 +65,7 @@ export const EndpointDetails = memo( const queryParams = useEndpointSelector(uiQueryParams); const policyStatus = useEndpointSelector( policyResponseStatus - ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; + ) as keyof typeof POLICY_STATUS_TO_BADGE_COLOR; const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { @@ -89,32 +74,37 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.os', { defaultMessage: 'OS', }), - description: details.host.os.full, + description: {details.host.os.full}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.agentStatus', { defaultMessage: 'Agent Status', }), description: ( - - + ), }, { title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { defaultMessage: 'Last Seen', }), - description: , + description: ( + + {' '} + + + ), }, ]; }, [details, hostStatus]); @@ -169,12 +159,14 @@ export const EndpointDetails = memo( description: ( - - {details.Endpoint.policy.applied.name} - + + + {details.Endpoint.policy.applied.name} + + {details.Endpoint.policy.applied.endpoint_policy_version && ( @@ -241,9 +233,11 @@ export const EndpointDetails = memo( }), description: ( - {details.host.ip.map((ip: string, index: number) => ( - - ))} + + {details.host.ip.map((ip: string, index: number) => ( + + ))} + ), }, @@ -251,13 +245,13 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { defaultMessage: 'Hostname', }), - description: details.host.hostname, + description: {details.host.hostname}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { defaultMessage: 'Endpoint Version', }), - description: details.agent.version, + description: {details.agent.version}, }, ]; }, [details.agent.version, details.host.hostname, details.host.ip]); @@ -275,22 +269,36 @@ export const EndpointDetails = memo( listItems={detailsResultsPolicy} data-test-subj="endpointDetailsPolicyList" /> - - + + - - - - - + + + + + + + + + + + + + ({ - [HostStatus.ERROR]: 'danger', - [HostStatus.ONLINE]: 'success', - [HostStatus.OFFLINE]: 'subdued', - [HostStatus.UNENROLLING]: 'warning', + [HostStatus.HEALTHY]: 'secondary', + [HostStatus.UNHEALTHY]: 'warning', + [HostStatus.UPDATING]: 'primary', + [HostStatus.OFFLINE]: 'default', + [HostStatus.INACTIVE]: 'default', }); export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< { [key in keyof typeof HostPolicyResponseActionStatus]: string } >({ - success: 'success', + success: 'secondary', warning: 'warning', failure: 'danger', - unsupported: 'subdued', + unsupported: 'default', }); export const POLICY_STATUS_TO_BADGE_COLOR = Object.freeze< 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 79e91fdeb813a..17ebff603ccfb 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 @@ -25,7 +25,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; -import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; +import { POLICY_STATUS_TO_TEXT } from './host_constants'; import { mockPolicyResultList } from '../../policy/store/test_mock_utils'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; @@ -232,9 +232,10 @@ describe('when on the list page', () => { > = []; let firstPolicyID: string; let firstPolicyRev: number; + beforeEach(() => { reactTestingLibrary.act(() => { - const mockedEndpointData = mockEndpointResultList({ total: 4 }); + const mockedEndpointData = mockEndpointResultList({ total: 5 }); const hostListData = mockedEndpointData.hosts; const queryStrategyVersion = mockedEndpointData.query_strategy_version; @@ -259,9 +260,9 @@ describe('when on the list page', () => { }; [ - { status: HostStatus.ERROR, policy: (p: Policy) => p }, + { status: HostStatus.UNHEALTHY, policy: (p: Policy) => p }, { - status: HostStatus.ONLINE, + status: HostStatus.HEALTHY, policy: (p: Policy) => { p.endpoint.id = 'xyz'; // represents change in endpoint policy assignment p.endpoint.revision = 1; @@ -276,7 +277,14 @@ describe('when on the list page', () => { }, }, { - status: HostStatus.UNENROLLING, + status: HostStatus.UPDATING, + policy: (p: Policy) => { + p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet + return p; + }, + }, + { + status: HostStatus.INACTIVE, policy: (p: Policy) => { p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet return p; @@ -317,7 +325,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const rows = await renderResult.findAllByRole('row'); - expect(rows).toHaveLength(5); + expect(rows).toHaveLength(6); }); it('should show total', async () => { const renderResult = render(); @@ -325,7 +333,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const total = await renderResult.findByTestId('endpointListTableTotal'); - expect(total.textContent).toEqual('4 Hosts'); + expect(total.textContent).toEqual('5 Hosts'); }); it('should display correct status', async () => { const renderResult = render(); @@ -334,23 +342,30 @@ describe('when on the list page', () => { }); const hostStatuses = await renderResult.findAllByTestId('rowHostStatus'); - expect(hostStatuses[0].textContent).toEqual('Error'); - expect(hostStatuses[0].querySelector('[data-euiicon-type][color="danger"]')).not.toBeNull(); + expect(hostStatuses[0].textContent).toEqual('Unhealthy'); + expect(hostStatuses[0].getAttribute('style')).toMatch( + /background-color\: rgb\(241\, 216\, 111\)\;/ + ); - expect(hostStatuses[1].textContent).toEqual('Online'); - expect( - hostStatuses[1].querySelector('[data-euiicon-type][color="success"]') - ).not.toBeNull(); + expect(hostStatuses[1].textContent).toEqual('Healthy'); + expect(hostStatuses[1].getAttribute('style')).toMatch( + /background-color\: rgb\(109\, 204\, 177\)\;/ + ); expect(hostStatuses[2].textContent).toEqual('Offline'); - expect( - hostStatuses[2].querySelector('[data-euiicon-type][color="subdued"]') - ).not.toBeNull(); + expect(hostStatuses[2].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); - expect(hostStatuses[3].textContent).toEqual('Unenrolling'); - expect( - hostStatuses[3].querySelector('[data-euiicon-type][color="warning"]') - ).not.toBeNull(); + expect(hostStatuses[3].textContent).toEqual('Updating'); + expect(hostStatuses[3].getAttribute('style')).toMatch( + /background-color\: rgb\(121\, 170\, 217\)\;/ + ); + + expect(hostStatuses[4].textContent).toEqual('Inactive'); + expect(hostStatuses[4].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); }); it('should display correct policy status', async () => { @@ -361,14 +376,18 @@ describe('when on the list page', () => { const policyStatuses = await renderResult.findAllByTestId('rowPolicyStatus'); policyStatuses.forEach((status, index) => { + const policyStatusToRGBColor: Array<[string, string]> = [ + ['Success', 'background-color: rgb(109, 204, 177);'], + ['Warning', 'background-color: rgb(241, 216, 111);'], + ['Failure', 'background-color: rgb(255, 126, 98);'], + ['Unsupported', 'background-color: rgb(211, 218, 230);'], + ]; + const policyStatusStyleMap: ReadonlyMap = new Map( + policyStatusToRGBColor + ); + const expectedStatusColor: string = policyStatusStyleMap.get(status.textContent!) ?? ''; expect(status.textContent).toEqual(POLICY_STATUS_TO_TEXT[generatedPolicyStatuses[index]]); - expect( - status.querySelector( - `[data-euiicon-type][color=${ - POLICY_STATUS_TO_HEALTH_COLOR[generatedPolicyStatuses[index]] - }]` - ) - ).not.toBeNull(); + expect(status.getAttribute('style')).toMatch(expectedStatusColor); }); }); @@ -378,7 +397,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const outOfDates = await renderResult.findAllByTestId('rowPolicyOutOfDate'); - expect(outOfDates).toHaveLength(3); + expect(outOfDates).toHaveLength(4); outOfDates.forEach((item, index) => { expect(item.textContent).toEqual('Out-of-date'); 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 c4c27bd493950..d28bf6b38fd31 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 @@ -5,14 +5,14 @@ * 2.0. */ -import React, { useMemo, useCallback, memo, useState } from 'react'; +import React, { useMemo, useCallback, memo, useState, useContext } from 'react'; import { EuiHorizontalRule, EuiBasicTable, EuiBasicTableColumn, EuiText, EuiLink, - EuiHealth, + EuiBadge, EuiToolTip, EuiSelectableProps, EuiSuperDatePicker, @@ -33,13 +33,14 @@ import { createStructuredSelector } from 'reselect'; import { useDispatch } from 'react-redux'; import { EuiContextMenuItemProps } from '@elastic/eui/src/components/context_menu/context_menu_item'; import { NavigateToAppOptions } from 'kibana/public'; +import { ThemeContext } from 'styled-components'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; import { isPolicyOutOfDate } from '../utils'; import { - HOST_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_HEALTH_COLOR, + HOST_STATUS_TO_BADGE_COLOR, + POLICY_STATUS_TO_BADGE_COLOR, POLICY_STATUS_TO_TEXT, } from './host_constants'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; @@ -72,11 +73,24 @@ const EndpointListNavLink = memo<{ name: string; href: string; route: string; + isBadge?: boolean; dataTestSubj: string; -}>(({ name, href, route, dataTestSubj }) => { +}>(({ name, href, route, isBadge = false, dataTestSubj }) => { const clickHandler = useNavigateByRouterEventHandler(route); + const theme = useContext(ThemeContext); - return ( + return isBadge ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {name} + + ) : ( // eslint-disable-next-line @elastic/eui/href-or-on-click { // eslint-disable-next-line react/display-name render: (hostStatus: HostInfo['host_status']) => { return ( - - + ); }, }, @@ -375,8 +389,8 @@ export const EndpointList = () => { }); const toRouteUrl = formatUrl(toRoutePath); return ( - @@ -384,9 +398,10 @@ export const EndpointList = () => { name={POLICY_STATUS_TO_TEXT[policy.status]} href={toRouteUrl} route={toRoutePath} + isBadge dataTestSubj="policyStatusCellLink" /> - + ); }, }, 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 578043f4321e9..5f572251daeda 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 @@ -10,7 +10,9 @@ import { HttpStart } from 'kibana/public'; import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_DELETE_API, + TRUSTED_APPS_GET_API, TRUSTED_APPS_LIST_API, + TRUSTED_APPS_UPDATE_API, TRUSTED_APPS_SUMMARY_API, } from '../../../../../common/endpoint/constants'; @@ -21,19 +23,39 @@ import { PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, GetTrustedAppsSummaryResponse, + PutTrustedAppUpdateRequest, + PutTrustedAppUpdateResponse, + PutTrustedAppsRequestParams, + GetOneTrustedAppRequestParams, + GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; import { resolvePathVariables } from './utils'; +import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { + getTrustedApp(params: GetOneTrustedAppRequestParams): Promise; getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; + updateTrustedApp( + params: PutTrustedAppsRequestParams, + request: PutTrustedAppUpdateRequest + ): Promise; + getPolicyList( + options?: Parameters[1] + ): ReturnType; } export class TrustedAppsHttpService implements TrustedAppsService { constructor(private http: HttpStart) {} + async getTrustedApp(params: GetOneTrustedAppRequestParams) { + return this.http.get( + resolvePathVariables(TRUSTED_APPS_GET_API, params) + ); + } + async getTrustedAppsList(request: GetTrustedAppsListRequest) { return this.http.get(TRUSTED_APPS_LIST_API, { query: request, @@ -50,7 +72,21 @@ export class TrustedAppsHttpService implements TrustedAppsService { }); } + async updateTrustedApp( + params: PutTrustedAppsRequestParams, + updatedTrustedApp: PutTrustedAppUpdateRequest + ) { + return this.http.put( + resolvePathVariables(TRUSTED_APPS_UPDATE_API, params), + { body: JSON.stringify(updatedTrustedApp) } + ); + } + async getTrustedAppsSummary() { return this.http.get(TRUSTED_APPS_SUMMARY_API); } + + getPolicyList(options?: Parameters[1]) { + return sendGetEndpointSpecificPackagePolicies(this.http, options); + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index ea934881f6220..1c1fca4b55abc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -7,6 +7,7 @@ import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; +import { GetPolicyListResponse } from '../../policy/types'; export interface Pagination { pageIndex: number; @@ -29,7 +30,9 @@ export interface TrustedAppsListPageLocation { page_index: number; page_size: number; view_type: ViewType; - show?: 'create'; + show?: 'create' | 'edit'; + /** Used for editing. The ID of the selected trusted app */ + id?: string; } export interface TrustedAppsListPageState { @@ -51,9 +54,13 @@ export interface TrustedAppsListPageState { entry: NewTrustedApp; isValid: boolean; }; + /** The trusted app to be edited (when in edit mode) */ + editItem?: AsyncResourceState; confirmed: boolean; submissionResourceState: AsyncResourceState; }; + /** A list of all available polices for use in associating TA to policies */ + policies: AsyncResourceState; location: TrustedAppsListPageLocation; active: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 66f4eff81dbdd..3f9e9d53f69e4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -8,7 +8,11 @@ import { ConditionEntry, ConditionEntryField, + EffectScope, + GlobalEffectScope, MacosLinuxConditionEntry, + MaybeImmutable, + PolicyEffectScope, WindowsConditionEntry, } from '../../../../../common/endpoint/types'; @@ -23,3 +27,15 @@ export const isMacosLinuxTrustedAppCondition = ( ): condition is MacosLinuxConditionEntry => { return condition.field !== ConditionEntryField.SIGNER; }; + +export const isGlobalEffectScope = ( + effectedScope: MaybeImmutable +): effectedScope is GlobalEffectScope => { + return effectedScope.type === 'global'; +}; + +export const isPolicyEffectScope = ( + effectedScope: MaybeImmutable +): effectedScope is PolicyEffectScope => { + return effectedScope.type === 'policy'; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index aaa05f550b208..34f48142c7032 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -9,6 +9,7 @@ import { Action } from 'redux'; import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, TrustedAppsListData } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export type TrustedAppsListDataOutdated = Action<'trustedAppsListDataOutdated'>; @@ -51,6 +52,10 @@ export type TrustedAppCreationDialogFormStateUpdated = Action<'trustedAppCreatio }; }; +export type TrustedAppCreationEditItemStateChanged = Action<'trustedAppCreationEditItemStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppCreationDialogConfirmed = Action<'trustedAppCreationDialogConfirmed'>; export type TrustedAppCreationDialogClosed = Action<'trustedAppCreationDialogClosed'>; @@ -59,6 +64,10 @@ export type TrustedAppsExistResponse = Action<'trustedAppsExistStateChanged'> & payload: AsyncResourceState; }; +export type TrustedAppsPoliciesStateChanged = Action<'trustedAppsPoliciesStateChanged'> & { + payload: AsyncResourceState; +}; + export type TrustedAppsPageAction = | TrustedAppsListDataOutdated | TrustedAppsListResourceStateChanged @@ -67,8 +76,10 @@ export type TrustedAppsPageAction = | TrustedAppDeletionDialogConfirmed | TrustedAppDeletionDialogClosed | TrustedAppCreationSubmissionResourceStateChanged + | TrustedAppCreationEditItemStateChanged | TrustedAppCreationDialogStarted | TrustedAppCreationDialogFormStateUpdated | TrustedAppCreationDialogConfirmed | TrustedAppsExistResponse + | TrustedAppsPoliciesStateChanged | TrustedAppCreationDialogClosed; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 3acb55904d298..ece2c9e29750f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -28,6 +28,7 @@ export const defaultNewTrustedApp = (): NewTrustedApp => ({ os: OperatingSystem.WINDOWS, entries: [defaultConditionEntry()], description: '', + effectScope: { type: 'global' }, }); export const initialDeletionDialogState = (): TrustedAppsListPageState['deletionDialog'] => ({ @@ -48,10 +49,12 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ }, deletionDialog: initialDeletionDialogState(), creationDialog: initialCreationDialogState(), + policies: { type: 'UninitialisedResourceState' }, location: { page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, show: undefined, + id: undefined, view_type: 'grid', }, active: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index 064b108848d2f..ed45d077dd0ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -21,10 +21,11 @@ import { } from '../test_utils'; import { TrustedAppsService } from '../service'; -import { Pagination, TrustedAppsListPageState } from '../state'; +import { Pagination, TrustedAppsListPageLocation, TrustedAppsListPageState } from '../state'; import { initialTrustedAppsPageState } from './builders'; import { trustedAppsPageReducer } from './reducer'; import { createTrustedAppsPageMiddleware } from './middleware'; +import { Immutable } from '../../../../../common/endpoint/types'; const initialNow = 111111; const dateNowMock = jest.fn(); @@ -32,7 +33,7 @@ dateNowMock.mockReturnValue(initialNow); Date.now = dateNowMock; -const initialState = initialTrustedAppsPageState(); +const initialState: Immutable = initialTrustedAppsPageState(); const createGetTrustedListAppsResponse = (pagination: Partial) => { const fullPagination = { ...createDefaultPagination(), ...pagination }; @@ -49,6 +50,9 @@ const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), deleteTrustedApp: jest.fn(), createTrustedApp: jest.fn(), + getPolicyList: jest.fn(), + updateTrustedApp: jest.fn(), + getTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { @@ -87,6 +91,15 @@ describe('middleware', () => { }; }; + const createLocationState = ( + params?: Partial + ): TrustedAppsListPageLocation => { + return { + ...initialState.location, + ...(params ?? {}), + }; + }; + beforeEach(() => { dateNowMock.mockReturnValue(initialNow); }); @@ -102,7 +115,10 @@ describe('middleware', () => { describe('refreshing list resource state', () => { it('refreshes the list when location changes and data gets outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -136,7 +152,10 @@ describe('middleware', () => { it('does not refresh the list when location changes and data does not get outdated', async () => { const pagination = { pageIndex: 2, pageSize: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; + const location = createLocationState({ + page_index: 2, + page_size: 50, + }); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -161,7 +180,7 @@ describe('middleware', () => { it('refreshes the list when data gets outdated with and outdate action', async () => { const newNow = 222222; const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -224,7 +243,10 @@ describe('middleware', () => { freshDataTimestamp: initialNow, }, active: true, - location: { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }, + location: createLocationState({ + page_index: 2, + page_size: 50, + }), }); const infiniteLoopTest = async () => { @@ -240,7 +262,7 @@ describe('middleware', () => { const entry = createSampleTrustedApp(3); const notFoundError = createServerApiError('Not Found'); const pagination = { pageIndex: 0, pageSize: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; + const location = createLocationState(); const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination); const listView = createLoadedListViewWithPagination(initialNow, pagination); const listViewNew = createLoadedListViewWithPagination(newNow, pagination); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 3e83b213f0f7e..7f940f14f9c6c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { Immutable, PostTrustedAppCreateRequest, @@ -54,7 +55,15 @@ import { getListTotalItemsCount, trustedAppsListPageActive, entriesExistState, + policiesState, + isEdit, + isFetchingEditTrustedAppItem, + editItemId, + editingTrustedApp, + getListItems, + editItemState, } from './selectors'; +import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; const createTrustedAppsListResourceStateChangedAction = ( newState: Immutable> @@ -139,9 +148,11 @@ const submitCreationIfNeeded = async ( store: ImmutableMiddlewareAPI, trustedAppsService: TrustedAppsService ) => { - const submissionResourceState = getCreationSubmissionResourceState(store.getState()); - const isValid = isCreationDialogFormValid(store.getState()); - const entry = getCreationDialogFormEntry(store.getState()); + const currentState = store.getState(); + const submissionResourceState = getCreationSubmissionResourceState(currentState); + const isValid = isCreationDialogFormValid(currentState); + const entry = getCreationDialogFormEntry(currentState); + const editMode = isEdit(currentState); if (isStaleResourceState(submissionResourceState) && entry !== undefined && isValid) { store.dispatch( @@ -152,12 +163,27 @@ const submitCreationIfNeeded = async ( ); try { + let responseTrustedApp: TrustedApp; + + if (editMode) { + responseTrustedApp = ( + await trustedAppsService.updateTrustedApp( + { id: editItemId(currentState)! }, + // TODO: try to remove the cast + entry as PostTrustedAppCreateRequest + ) + ).data; + } else { + // TODO: try to remove the cast + responseTrustedApp = ( + await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest) + ).data; + } + store.dispatch( createTrustedAppCreationSubmissionResourceStateChanged({ type: 'LoadedResourceState', - // TODO: try to remove the cast - data: (await trustedAppsService.createTrustedApp(entry as PostTrustedAppCreateRequest)) - .data, + data: responseTrustedApp, }) ); store.dispatch({ @@ -268,6 +294,139 @@ const checkTrustedAppsExistIfNeeded = async ( } }; +export const retrieveListOfPoliciesIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const currentPoliciesState = policiesState(currentState); + const isLoading = isLoadingResourceState(currentPoliciesState); + const isPageActive = trustedAppsListPageActive(currentState); + const isCreateFlow = isCreationDialogLocation(currentState); + + if (isPageActive && isCreateFlow && !isLoading) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: currentPoliciesState, + } as TrustedAppsListPageState['policies'], + }); + + try { + const policyList = await trustedAppsService.getPolicyList({ + query: { + page: 1, + perPage: 1000, + }, + }); + + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'LoadedResourceState', + data: policyList, + }, + }); + } catch (error) { + dispatch({ + type: 'trustedAppsPoliciesStateChanged', + payload: { + type: 'FailedResourceState', + error: error.body || error, + lastLoadedState: getLastLoadedResourceState(policiesState(getState())), + }, + }); + } + } +}; + +const fetchEditTrustedAppIfNeeded = async ( + { getState, dispatch }: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const currentState = getState(); + const isPageActive = trustedAppsListPageActive(currentState); + const isEditFlow = isEdit(currentState); + const isAlreadyFetching = isFetchingEditTrustedAppItem(currentState); + const editTrustedAppId = editItemId(currentState); + + if (isPageActive && isEditFlow && !isAlreadyFetching) { + if (!editTrustedAppId) { + const errorMessage = i18n.translate( + 'xpack.securitySolution.trustedapps.middleware.editIdMissing', + { + defaultMessage: 'No id provided', + } + ); + + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: Object.assign(new Error(errorMessage), { statusCode: 404, error: errorMessage }), + }, + }); + return; + } + + let trustedAppForEdit = editingTrustedApp(currentState); + + // If Trusted App is already loaded, then do nothing + if (trustedAppForEdit && trustedAppForEdit.id === editTrustedAppId) { + return; + } + + // See if we can get the Trusted App record from the current list of Trusted Apps being displayed + trustedAppForEdit = getListItems(currentState).find((ta) => ta.id === editTrustedAppId); + + try { + // Retrieve Trusted App record via API if it was not in the list data. + // This would be the case when linking from another place or using an UUID for a Trusted App + // that is not currently displayed on the list view. + if (!trustedAppForEdit) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadingResourceState', + // No easy way to get around this that I can see. `previousState` does not + // seem to allow everything that `editItem` state can hold, so not even sure if using + // type guards would work here + // @ts-ignore + previousState: editItemState(currentState)!, + }, + }); + + trustedAppForEdit = (await trustedAppsService.getTrustedApp({ id: editTrustedAppId })).data; + } + + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'LoadedResourceState', + data: trustedAppForEdit, + }, + }); + + dispatch({ + type: 'trustedAppCreationDialogFormStateUpdated', + payload: { + entry: toUpdateTrustedApp(trustedAppForEdit), + isValid: true, + }, + }); + } catch (e) { + dispatch({ + type: 'trustedAppCreationEditItemStateChanged', + payload: { + type: 'FailedResourceState', + error: e, + }, + }); + } + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -282,6 +441,8 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl') { updateCreationDialogIfNeeded(store); + retrieveListOfPoliciesIfNeeded(store, trustedAppsService); + fetchEditTrustedAppIfNeeded(store, trustedAppsService); } if (action.type === 'trustedAppCreationDialogConfirmed') { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 5f37d0d674558..6965172ef773d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -37,7 +37,13 @@ describe('reducer', () => { expect(result).toStrictEqual({ ...initialState, - location: { page_index: 5, page_size: 50, show: 'create', view_type: 'list' }, + location: { + page_index: 5, + page_size: 50, + show: 'create', + view_type: 'list', + id: undefined, + }, active: true, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index aff5cacf081c6..ea7bbb44c9bf2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -29,6 +29,8 @@ import { TrustedAppCreationDialogConfirmed, TrustedAppCreationDialogClosed, TrustedAppsExistResponse, + TrustedAppsPoliciesStateChanged, + TrustedAppCreationEditItemStateChanged, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -37,7 +39,7 @@ import { initialDeletionDialogState, initialTrustedAppsPageState, } from './builders'; -import { entriesExistState } from './selectors'; +import { entriesExistState, trustedAppsListPageActive } from './selectors'; type StateReducer = ImmutableReducer; type CaseReducer = ( @@ -110,7 +112,7 @@ const trustedAppCreationDialogStarted: CaseReducer = ( + state, + action +) => { + return { + ...state, + creationDialog: { ...state.creationDialog, editItem: action.payload }, + }; +}; + const trustedAppCreationDialogConfirmed: CaseReducer = ( state ) => { @@ -155,6 +167,16 @@ const updateEntriesExists: CaseReducer = (state, { pay return state; }; +const updatePolicies: CaseReducer = (state, { payload }) => { + if (trustedAppsListPageActive(state)) { + return { + ...state, + policies: payload, + }; + } + return state; +}; + export const trustedAppsPageReducer: StateReducer = ( state = initialTrustedAppsPageState(), action @@ -187,6 +209,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppCreationDialogFormStateUpdated': return trustedAppCreationDialogFormStateUpdated(state, action); + case 'trustedAppCreationEditItemStateChanged': + return handleUpdateToEditItemState(state, action); + case 'trustedAppCreationDialogConfirmed': return trustedAppCreationDialogConfirmed(state, action); @@ -198,6 +223,9 @@ export const trustedAppsPageReducer: StateReducer = ( case 'trustedAppsExistStateChanged': return updateEntriesExists(state, action); + + case 'trustedAppsPoliciesStateChanged': + return updatePolicies(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index baa68eb314140..7c131c3eaa7a9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -24,6 +24,7 @@ import { TrustedAppsListPageLocation, TrustedAppsListPageState, } from '../state'; +import { GetPolicyListResponse } from '../../policy/types'; export const needsRefreshOfListData = (state: Immutable): boolean => { const freshDataTimestamp = state.listView.freshDataTimestamp; @@ -130,7 +131,7 @@ export const getDeletionDialogEntry = ( }; export const isCreationDialogLocation = (state: Immutable): boolean => { - return state.location.show === 'create'; + return !!state.location.show; }; export const getCreationSubmissionResourceState = ( @@ -185,3 +186,56 @@ export const entriesExist: (state: Immutable) => boole export const trustedAppsListPageActive: (state: Immutable) => boolean = ( state ) => state.active; + +export const policiesState = ( + state: Immutable +): Immutable => state.policies; + +export const loadingPolicies: ( + state: Immutable +) => boolean = createSelector(policiesState, (policies) => isLoadingResourceState(policies)); + +export const listOfPolicies: ( + state: Immutable +) => Immutable = createSelector(policiesState, (policies) => { + return isLoadedResourceState(policies) ? policies.data.items : []; +}); + +export const isEdit: (state: Immutable) => boolean = createSelector( + getCurrentLocation, + ({ show }) => { + return show === 'edit'; + } +); + +export const editItemId: ( + state: Immutable +) => string | undefined = createSelector(getCurrentLocation, ({ id }) => { + return id; +}); + +export const editItemState: ( + state: Immutable +) => Immutable['creationDialog']['editItem'] = (state) => { + return state.creationDialog.editItem; +}; + +export const isFetchingEditTrustedAppItem: ( + state: Immutable +) => boolean = createSelector(editItemState, (editTrustedAppState) => { + return editTrustedAppState ? isLoadingResourceState(editTrustedAppState) : false; +}); + +export const editTrustedAppFetchError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(editItemState, (itemForEditState) => { + return itemForEditState && getCurrentResourceError(itemForEditState); +}); + +export const editingTrustedApp: ( + state: Immutable +) => undefined | Immutable = createSelector(editItemState, (editTrustedAppState) => { + if (editTrustedAppState && isLoadedResourceState(editTrustedAppState)) { + return editTrustedAppState.data; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index faf111b1a55d8..faffc6b04a0cd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -44,12 +44,16 @@ const generate = (count: number, generator: (i: number) => T) => export const createSampleTrustedApp = (i: number, longTexts?: boolean): TrustedApp => { return { id: String(i), + version: 'abc123', name: generate(longTexts ? 10 : 1, () => `trusted app ${i}`).join(' '), description: generate(longTexts ? 10 : 1, () => `Trusted App ${i}`).join(' '), created_at: '1 minute ago', created_by: 'someone', + updated_at: '1 minute ago', + updated_by: 'someone', os: OPERATING_SYSTEMS[i % 3], entries: [], + effectScope: { type: 'global' }, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap new file mode 100644 index 0000000000000..f0831815fb65c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -0,0 +1,5573 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`When on the Trusted Apps Page and the Add Trusted App button is clicked and there is a feature flag for agents policy should display agents policy if feature flag is enabled 1`] = ` +Object { + "asFragment": [Function], + "baseElement": + .c0 { + padding: 24px; +} + +.c0.siemWrapperPage--fullHeight { + height: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; +} + +.c0.siemWrapperPage--noPadding { + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; +} + +.c0.siemWrapperPage--withTimeline { + padding-bottom: 70px; +} + +.c3 { + margin-top: 8px; +} + +.c3 .siemSubtitle__item { + color: #6a717d; + font-size: 12px; + line-height: 1.5; +} + +.c1 { + margin-bottom: 24px; +} + +.c2 { + display: block; +} + +.c4 .euiFlyout { + z-index: 4001; +} + +.c5 .and-badge { + padding-top: 20px; + padding-bottom: calc(32px + (8px * 2) + 3px); +} + +.c5 .group-entries { + margin-bottom: 8px; +} + +.c5 .group-entries > * { + margin-bottom: 8px; +} + +.c5 .group-entries > *:last-child { + margin-bottom: 0; +} + +.c5 .and-button { + min-width: 95px; +} + +.c6 .policy-name .euiSelectableListItem__text { + -webkit-text-decoration: none !important; + text-decoration: none !important; + color: #343741 !important; +} + +.c7 { + background-color: #f5f7fa; + padding: 16px; +} + +.c10 { + padding: 16px; +} + +.c8.c8.c8 { + width: 40%; +} + +.c9.c9.c9 { + width: 60%; +} + +@media only screen and (min-width:575px) { + .c3 .siemSubtitle__item { + display: inline-block; + margin-right: 16px; + } + + .c3 .siemSubtitle__item:last-child { + margin-right: 0; + } +} + +
+
+
+
+
+

+ Trusted Applications +

+
+

+ Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security. +

+
+
+
+ +
+
+
+
+ + +
+
+
+
+